From 764314c725fa4a43ef6b82dd16c0660a2b0165e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:06:14 +0000 Subject: [PATCH 01/18] Initial plan From 25725524b961c159a1b442bad9cbe1c8f5b3bf73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:23:43 +0000 Subject: [PATCH 02/18] Update safe-output job creation functions to use BuildSafeOutputType and remove buildCommandOnlyCondition Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 2 +- .github/workflows/tidy.lock.yml | 2 +- pkg/workflow/add_comment.go | 31 ++--------------- pkg/workflow/add_labels.go | 13 +------ pkg/workflow/command.go | 30 ---------------- pkg/workflow/compile_test.go | 22 ++++++------ pkg/workflow/create_code_scanning_alert.go | 13 ++----- pkg/workflow/create_discussion.go | 12 ++----- pkg/workflow/create_pr_review_comment.go | 18 ++-------- pkg/workflow/create_pr_review_comment_test.go | 4 +-- pkg/workflow/publish_assets.go | 10 +----- pkg/workflow/push_to_pull_request_branch.go | 31 ++--------------- .../push_to_pull_request_branch_test.go | 8 ++--- pkg/workflow/update_issue.go | 34 ++----------------- 14 files changed, 32 insertions(+), 198 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index cadb414bdb7..719b3143ffb 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2524,7 +2524,7 @@ jobs: create_issue_comment: needs: agent - if: github.event.issue.number || github.event.pull_request.number + if: contains(needs.agent.outputs.output_types, 'add-comment') runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index b14cd38a41d..47ff2dd80a1 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -2444,7 +2444,7 @@ jobs: push_to_pull_request_branch: needs: agent - if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request + if: contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch') runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index b2a4d2e42d4..fcb97a4c8ae 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -57,35 +57,8 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam "comment_url": "${{ steps.add_comment.outputs.comment_url }}", } - // Determine the job condition based on target configuration - var baseCondition string - if data.SafeOutputs.AddComments.Target == "*" { - // Allow the job to run in any context when target is "*" - baseCondition = "always()" // This allows the job to run even without triggering issue/PR - } else { - // Default behavior: only run in issue or PR context - baseCondition = "github.event.issue.number || github.event.pull_request.number" - } - - // If this is a command workflow, combine the command trigger condition with the base condition - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - - // Combine command condition with base condition using AND - if baseCondition == "always()" { - // If base condition is always(), just use the command condition - jobCondition = commandConditionStr - } else { - // Combine both conditions with AND - jobCondition = fmt.Sprintf("(%s) && (%s)", commandConditionStr, baseCondition) - } - } else { - // No command trigger, just use the base condition - jobCondition = baseCondition - } + // Determine the job condition for command workflows + jobCondition := BuildSafeOutputType("add-comment").Render() job := &Job{ Name: "create_issue_comment", diff --git a/pkg/workflow/add_labels.go b/pkg/workflow/add_labels.go index 95002adeebe..a603c461acd 100644 --- a/pkg/workflow/add_labels.go +++ b/pkg/workflow/add_labels.go @@ -72,18 +72,7 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str } // Determine the job condition for command workflows - var baseCondition = "github.event.issue.number || github.event.pull_request.number" // Only run in issue or PR context - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - // Combine command condition with base condition using AND - jobCondition = fmt.Sprintf("(%s) && (%s)", commandConditionStr, baseCondition) - } else { - // No command trigger, just use the base condition - jobCondition = baseCondition - } + jobCondition := BuildSafeOutputType("add-labels").Render() job := &Job{ Name: "add_labels", diff --git a/pkg/workflow/command.go b/pkg/workflow/command.go index ef884b824e7..a93cd6d675f 100644 --- a/pkg/workflow/command.go +++ b/pkg/workflow/command.go @@ -2,36 +2,6 @@ package workflow import "fmt" -// buildCommandOnlyCondition creates a condition that only applies to command mentions in comment-related events -// Unlike buildEventAwareCommandCondition, this does NOT allow non-comment events to pass through -func buildCommandOnlyCondition(commandName string) ConditionNode { - // Define the command condition using proper expression nodes - commandText := fmt.Sprintf("/%s", commandName) - - // Build command checks for different content sources using expression nodes - issueBodyCheck := BuildContains( - BuildPropertyAccess("github.event.issue.body"), - BuildStringLiteral(commandText), - ) - commentBodyCheck := BuildContains( - BuildPropertyAccess("github.event.comment.body"), - BuildStringLiteral(commandText), - ) - prBodyCheck := BuildContains( - BuildPropertyAccess("github.event.pull_request.body"), - BuildStringLiteral(commandText), - ) - - // Combine all command checks with OR - only true when command is mentioned - return &DisjunctionNode{ - Terms: []ConditionNode{ - issueBodyCheck, - commentBodyCheck, - prBodyCheck, - }, - } -} - // buildEventAwareCommandCondition creates a condition that only applies command checks to comment-related events func buildEventAwareCommandCondition(commandName string, hasOtherEvents bool) ConditionNode { // Define the command condition using proper expression nodes diff --git a/pkg/workflow/compile_test.go b/pkg/workflow/compile_test.go index f4d806b6ab5..6866854b0e9 100644 --- a/pkg/workflow/compile_test.go +++ b/pkg/workflow/compile_test.go @@ -573,8 +573,8 @@ This workflow tests the create_issue_comment job generation. t.Error("Expected github-script action to be used in create_issue_comment job") } - // Verify job has conditional execution - if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + // Verify job has conditional execution using BuildSafeOutputType + if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-comment')") { t.Error("Expected create_issue_comment job to have conditional execution") } @@ -643,8 +643,8 @@ This workflow tests that issue comment job is skipped for non-issue/PR events. t.Error("Expected 'create_issue_comment' job to be in generated workflow") } - // Verify job has conditional execution to skip when not in issue/PR context - if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + // Verify job has conditional execution using BuildSafeOutputType + if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-comment')") { t.Error("Expected create_issue_comment job to have conditional execution for skipping") } @@ -1116,12 +1116,10 @@ This workflow tests the add_labels job generation. t.Error("Expected github-script action to be used in add_labels job") } - // Verify job has conditional execution - if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + // Verify job has conditional execution using BuildSafeOutputType + if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-labels')") { t.Error("Expected add_labels job to have conditional execution") } - - // Verify job dependencies if !strings.Contains(lockContent, "needs: agent") { t.Error("Expected add_labels job to depend on main job") } @@ -1192,8 +1190,8 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. } lockContent := string(lockBytes) - // Verify job has conditional execution - if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + // Verify job has conditional execution using BuildSafeOutputType + if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-labels')") { t.Error("Expected add_labels job to have conditional execution") } @@ -1268,8 +1266,8 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. t.Error("Expected 'add_labels' job to be in generated workflow") } - // Verify job has conditional execution - if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + // Verify job has conditional execution using BuildSafeOutputType + if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-labels')") { t.Error("Expected add_labels job to have conditional execution") } diff --git a/pkg/workflow/create_code_scanning_alert.go b/pkg/workflow/create_code_scanning_alert.go index 0fd6df9292e..27a10441671 100644 --- a/pkg/workflow/create_code_scanning_alert.go +++ b/pkg/workflow/create_code_scanning_alert.go @@ -81,17 +81,8 @@ func (c *Compiler) buildCreateOutputCodeScanningAlertJob(data *WorkflowData, mai "codeql_uploaded": "${{ steps.create_code_scanning_alert.outputs.codeql_uploaded }}", } - // Build job condition - repository security advisories can run in any context unlike PR review comments - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - jobCondition = commandConditionStr - } else { - // No specific condition needed - repository security advisories can run anytime - jobCondition = "" - } + // Determine the job condition for command workflows + jobCondition := BuildSafeOutputType("create-code-scanning-alert").Render() job := &Job{ Name: "create_code_scanning_alert", diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index 7ac3e498192..4d5a4191180 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -90,16 +90,8 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam "discussion_url": "${{ steps.create_discussion.outputs.discussion_url }}", } - // Determine the job condition based on command configuration - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - jobCondition = commandConditionStr - } else { - jobCondition = "" // No conditional execution - } + // Determine the job condition for command workflows + jobCondition := BuildSafeOutputType("create-discussion").Render() job := &Job{ Name: "create_discussion", diff --git a/pkg/workflow/create_pr_review_comment.go b/pkg/workflow/create_pr_review_comment.go index 63ae36fc4f4..35f8b775d5f 100644 --- a/pkg/workflow/create_pr_review_comment.go +++ b/pkg/workflow/create_pr_review_comment.go @@ -52,22 +52,8 @@ func (c *Compiler) buildCreateOutputPullRequestReviewCommentJob(data *WorkflowDa "review_comment_url": "${{ steps.create_pr_review_comment.outputs.review_comment_url }}", } - // We only run in pull request context, Note that in pull request comments only github.event.issue.pull_request is set. - baseCondition := "(github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request" - - // If this is a command workflow, combine the command trigger condition with the base condition - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - - // Combine command condition with base condition using AND - jobCondition = fmt.Sprintf("(%s) && (%s)", commandConditionStr, baseCondition) - } else { - // No command trigger, just use the base condition - jobCondition = baseCondition - } + // Determine the job condition for command workflows + jobCondition := BuildSafeOutputType("create-pull-request-review-comment").Render() job := &Job{ Name: "create_pr_review_comment", diff --git a/pkg/workflow/create_pr_review_comment_test.go b/pkg/workflow/create_pr_review_comment_test.go index 1f05c5e065f..b4e9544b3c9 100644 --- a/pkg/workflow/create_pr_review_comment_test.go +++ b/pkg/workflow/create_pr_review_comment_test.go @@ -242,8 +242,8 @@ This workflow tests job generation for PR review comments. t.Error("Expected create_pr_review_comment job to be generated") } - // Verify job condition is correct for PR context - if !strings.Contains(workflowContent, "if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request") { + // Verify job condition uses BuildSafeOutputType + if !strings.Contains(workflowContent, "if: contains(needs.agent.outputs.output_types, 'create-pull-request-review-comment')") { t.Error("Expected job condition to check for pull request context") } diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 8c54122010f..5e568c3d507 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -166,15 +166,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, } // Determine the job condition for command workflows - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - jobCondition = commandConditionStr - } else { - jobCondition = "" // No conditional execution - } + jobCondition := BuildSafeOutputType("upload-assets").Render() // Set base permissions permissions := "permissions:\n contents: write # Required for creating orphaned branch and pushing assets" diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index 2e646f4bb2e..fd3179ff7e1 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -95,35 +95,8 @@ func (c *Compiler) buildCreateOutputPushToPullRequestBranchJob(data *WorkflowDat "push_url": "${{ steps.push_to_pull_request_branch.outputs.push_url }}", } - // Determine the job condition based on target configuration - var baseCondition string - if data.SafeOutputs.PushToPullRequestBranch.Target == "*" { - // Allow pushing to any pull request - no specific context required - baseCondition = "always()" - } else { - // Default behavior: only run in pull request context, or issue context with a linked PR - baseCondition = "(github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request" - } - - // If this is a command workflow, combine the command trigger condition with the base condition - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - - // Combine command condition with base condition using AND - if baseCondition == "always()" { - // If base condition is always(), just use the command condition - jobCondition = commandConditionStr - } else { - // Combine both conditions with AND - jobCondition = fmt.Sprintf("(%s) && (%s)", commandConditionStr, baseCondition) - } - } else { - // No command trigger, just use the base condition - jobCondition = baseCondition - } + // Determine the job condition for command workflows + jobCondition := BuildSafeOutputType("push-to-pull-request-branch").Render() job := &Job{ Name: "push_to_pull_request_branch", diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index bff569a0aa4..e44909c6fc1 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -70,8 +70,8 @@ Please make changes and push them to the feature branch. t.Errorf("Generated workflow should have dependency on main job") } - // Verify conditional execution for pull request context - if !strings.Contains(lockContentStr, "if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request") { + // Verify conditional execution using BuildSafeOutputType + if !strings.Contains(lockContentStr, "if: contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')") { t.Errorf("Generated workflow should have pull request context condition") } } @@ -280,8 +280,8 @@ This workflow has minimal push-to-pull-request-branch configuration. t.Errorf("Generated workflow should not contain target configuration when not specified") } - // Verify default conditional execution for pull request context - if !strings.Contains(lockContentStr, "if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request") { + // Verify default conditional execution using BuildSafeOutputType + if !strings.Contains(lockContentStr, "if: contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')") { t.Errorf("Generated workflow should have default pull request context condition") } } diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 75e52a39da9..15a96f4199a 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -61,38 +61,8 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa "issue_url": "${{ steps.update_issue.outputs.issue_url }}", } - // Determine the job condition based on target configuration - var baseCondition string - if data.SafeOutputs.UpdateIssues.Target == "*" { - // Allow updates to any issue - no specific context required - baseCondition = "always()" - } else if data.SafeOutputs.UpdateIssues.Target != "" { - // Explicit issue number specified - no specific context required - baseCondition = "always()" - } else { - // Default behavior: only update triggering issue - baseCondition = "github.event.issue.number" - } - - // If this is a command workflow, combine the command trigger condition with the base condition - var jobCondition string - if data.Command != "" { - // Build the command trigger condition - commandCondition := buildCommandOnlyCondition(data.Command) - commandConditionStr := commandCondition.Render() - - // Combine command condition with base condition using AND - if baseCondition == "always()" { - // If base condition is always(), just use the command condition - jobCondition = commandConditionStr - } else { - // Combine both conditions with AND - jobCondition = fmt.Sprintf("(%s) && (%s)", commandConditionStr, baseCondition) - } - } else { - // No command trigger, just use the base condition - jobCondition = baseCondition - } + // Determine the job condition for command workflows + jobCondition := BuildSafeOutputType("update-issue").Render() job := &Job{ Name: "update_issue", From 332dfbbeff071e08992d620838543103965cdb42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:51:55 +0000 Subject: [PATCH 03/18] Retain original base conditions AND them with BuildSafeOutputType, remove buildCommandOnlyCondition as requested Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 3 +- .github/workflows/tidy.lock.yml | 4 +- pkg/workflow/add_comment.go | 23 +++++- pkg/workflow/add_labels.go | 6 +- pkg/workflow/compile_test.go | 80 ++++++++++++++++--- pkg/workflow/create_code_scanning_alert.go | 5 +- pkg/workflow/create_discussion.go | 5 +- pkg/workflow/create_pr_review_comment.go | 8 +- pkg/workflow/create_pr_review_comment_test.go | 17 +++- pkg/workflow/publish_assets.go | 5 +- pkg/workflow/push_to_pull_request_branch.go | 23 +++++- .../push_to_pull_request_branch_test.go | 35 +++++++- pkg/workflow/update_issue.go | 26 +++++- 13 files changed, 206 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 719b3143ffb..48ba59d59a2 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2524,7 +2524,8 @@ jobs: create_issue_comment: needs: agent - if: contains(needs.agent.outputs.output_types, 'add-comment') + if: > + (contains(needs.agent.outputs.output_types, 'add-comment')) && (github.event.issue.number || github.event.pull_request.number) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 47ff2dd80a1..a3cc899d249 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -2444,7 +2444,9 @@ jobs: push_to_pull_request_branch: needs: agent - if: contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch') + if: > + (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && ((github.event.issue.number && + github.event.issue.pull_request) || github.event.pull_request) runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index fcb97a4c8ae..0ae94d32d31 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -57,8 +57,27 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam "comment_url": "${{ steps.add_comment.outputs.comment_url }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("add-comment").Render() + // Determine the job condition based on target configuration + var baseCondition string + if data.SafeOutputs.AddComments.Target == "*" { + // Allow the job to run in any context when target is "*" + baseCondition = "always()" // This allows the job to run even without triggering issue/PR + } else { + // Default behavior: only run in issue or PR context + baseCondition = "github.event.issue.number || github.event.pull_request.number" + } + + // Combine the base condition with the safe output type condition + var jobCondition string + safeOutputCondition := BuildSafeOutputType("add-comment").Render() + + if baseCondition == "always()" { + // If base condition is always(), just use the safe output condition + jobCondition = safeOutputCondition + } else { + // Combine both conditions with AND + jobCondition = fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + } job := &Job{ Name: "create_issue_comment", diff --git a/pkg/workflow/add_labels.go b/pkg/workflow/add_labels.go index a603c461acd..42cd21593f6 100644 --- a/pkg/workflow/add_labels.go +++ b/pkg/workflow/add_labels.go @@ -71,8 +71,10 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str "labels_added": "${{ steps.add_labels.outputs.labels_added }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("add-labels").Render() + // Combine the base condition with the safe output type condition + var baseCondition = "github.event.issue.number || github.event.pull_request.number" // Only run in issue or PR context + safeOutputCondition := BuildSafeOutputType("add-labels").Render() + jobCondition := fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) job := &Job{ Name: "add_labels", diff --git a/pkg/workflow/compile_test.go b/pkg/workflow/compile_test.go index 6866854b0e9..93981b0af47 100644 --- a/pkg/workflow/compile_test.go +++ b/pkg/workflow/compile_test.go @@ -573,8 +573,20 @@ This workflow tests the create_issue_comment job generation. t.Error("Expected github-script action to be used in create_issue_comment job") } - // Verify job has conditional execution using BuildSafeOutputType - if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-comment')") { + // Verify job has conditional execution using BuildSafeOutputType combined with base condition + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'add-comment')", + "github.event.issue.number", + "github.event.pull_request.number", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(lockContent, part) { + conditionFound = false + break + } + } + if !conditionFound { t.Error("Expected create_issue_comment job to have conditional execution") } @@ -643,8 +655,20 @@ This workflow tests that issue comment job is skipped for non-issue/PR events. t.Error("Expected 'create_issue_comment' job to be in generated workflow") } - // Verify job has conditional execution using BuildSafeOutputType - if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-comment')") { + // Verify job has conditional execution using BuildSafeOutputType combined with base condition + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'add-comment')", + "github.event.issue.number", + "github.event.pull_request.number", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(lockContent, part) { + conditionFound = false + break + } + } + if !conditionFound { t.Error("Expected create_issue_comment job to have conditional execution for skipping") } @@ -1116,8 +1140,20 @@ This workflow tests the add_labels job generation. t.Error("Expected github-script action to be used in add_labels job") } - // Verify job has conditional execution using BuildSafeOutputType - if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-labels')") { + // Verify job has conditional execution using BuildSafeOutputType combined with base condition + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'add-labels')", + "github.event.issue.number", + "github.event.pull_request.number", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(lockContent, part) { + conditionFound = false + break + } + } + if !conditionFound { t.Error("Expected add_labels job to have conditional execution") } if !strings.Contains(lockContent, "needs: agent") { @@ -1190,8 +1226,20 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. } lockContent := string(lockBytes) - // Verify job has conditional execution using BuildSafeOutputType - if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-labels')") { + // Verify job has conditional execution using BuildSafeOutputType combined with base condition + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'add-labels')", + "github.event.issue.number", + "github.event.pull_request.number", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(lockContent, part) { + conditionFound = false + break + } + } + if !conditionFound { t.Error("Expected add_labels job to have conditional execution") } @@ -1266,8 +1314,20 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. t.Error("Expected 'add_labels' job to be in generated workflow") } - // Verify job has conditional execution using BuildSafeOutputType - if !strings.Contains(lockContent, "if: contains(needs.agent.outputs.output_types, 'add-labels')") { + // Verify job has conditional execution using BuildSafeOutputType combined with base condition + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'add-labels')", + "github.event.issue.number", + "github.event.pull_request.number", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(lockContent, part) { + conditionFound = false + break + } + } + if !conditionFound { t.Error("Expected add_labels job to have conditional execution") } diff --git a/pkg/workflow/create_code_scanning_alert.go b/pkg/workflow/create_code_scanning_alert.go index 27a10441671..50e9031d29a 100644 --- a/pkg/workflow/create_code_scanning_alert.go +++ b/pkg/workflow/create_code_scanning_alert.go @@ -81,8 +81,9 @@ func (c *Compiler) buildCreateOutputCodeScanningAlertJob(data *WorkflowData, mai "codeql_uploaded": "${{ steps.create_code_scanning_alert.outputs.codeql_uploaded }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("create-code-scanning-alert").Render() + // Build job condition - repository security advisories can run in any context unlike PR review comments + safeOutputCondition := BuildSafeOutputType("create-code-scanning-alert").Render() + jobCondition := safeOutputCondition job := &Job{ Name: "create_code_scanning_alert", diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index 4d5a4191180..d0bf70acecc 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -90,8 +90,9 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam "discussion_url": "${{ steps.create_discussion.outputs.discussion_url }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("create-discussion").Render() + // Determine the job condition based on safe output type + safeOutputCondition := BuildSafeOutputType("create-discussion").Render() + jobCondition := safeOutputCondition job := &Job{ Name: "create_discussion", diff --git a/pkg/workflow/create_pr_review_comment.go b/pkg/workflow/create_pr_review_comment.go index 35f8b775d5f..29df5554e5c 100644 --- a/pkg/workflow/create_pr_review_comment.go +++ b/pkg/workflow/create_pr_review_comment.go @@ -52,8 +52,12 @@ func (c *Compiler) buildCreateOutputPullRequestReviewCommentJob(data *WorkflowDa "review_comment_url": "${{ steps.create_pr_review_comment.outputs.review_comment_url }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("create-pull-request-review-comment").Render() + // We only run in pull request context, Note that in pull request comments only github.event.issue.pull_request is set. + baseCondition := "(github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request" + + // Combine the base condition with the safe output type condition + safeOutputCondition := BuildSafeOutputType("create-pull-request-review-comment").Render() + jobCondition := fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) job := &Job{ Name: "create_pr_review_comment", diff --git a/pkg/workflow/create_pr_review_comment_test.go b/pkg/workflow/create_pr_review_comment_test.go index b4e9544b3c9..873bd981064 100644 --- a/pkg/workflow/create_pr_review_comment_test.go +++ b/pkg/workflow/create_pr_review_comment_test.go @@ -242,8 +242,21 @@ This workflow tests job generation for PR review comments. t.Error("Expected create_pr_review_comment job to be generated") } - // Verify job condition uses BuildSafeOutputType - if !strings.Contains(workflowContent, "if: contains(needs.agent.outputs.output_types, 'create-pull-request-review-comment')") { + // Verify job condition uses BuildSafeOutputType combined with pull request context + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'create-pull-request-review-comment')", + "github.event.issue.number", + "github.event.issue.pull_request", + "github.event.pull_request", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(workflowContent, part) { + conditionFound = false + break + } + } + if !conditionFound { t.Error("Expected job condition to check for pull request context") } diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 5e568c3d507..02148af6ddf 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -165,8 +165,9 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, "branch_name": "${{ steps.upload_assets.outputs.branch_name }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("upload-assets").Render() + // Determine the job condition based on safe output type + safeOutputCondition := BuildSafeOutputType("publish-assets").Render() + jobCondition := safeOutputCondition // Set base permissions permissions := "permissions:\n contents: write # Required for creating orphaned branch and pushing assets" diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index fd3179ff7e1..a9052943e5a 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -95,8 +95,27 @@ func (c *Compiler) buildCreateOutputPushToPullRequestBranchJob(data *WorkflowDat "push_url": "${{ steps.push_to_pull_request_branch.outputs.push_url }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("push-to-pull-request-branch").Render() + // Determine the job condition based on target configuration + var baseCondition string + if data.SafeOutputs.PushToPullRequestBranch.Target == "*" { + // Allow pushing to any pull request - no specific context required + baseCondition = "always()" + } else { + // Default behavior: only run in pull request context, or issue context with a linked PR + baseCondition = "(github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request" + } + + // Combine the base condition with the safe output type condition + var jobCondition string + safeOutputCondition := BuildSafeOutputType("push-to-pull-request-branch").Render() + + if baseCondition == "always()" { + // If base condition is always(), just use the safe output condition + jobCondition = safeOutputCondition + } else { + // Combine both conditions with AND + jobCondition = fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + } job := &Job{ Name: "push_to_pull_request_branch", diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index e44909c6fc1..32fd65e5896 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -70,8 +70,22 @@ Please make changes and push them to the feature branch. t.Errorf("Generated workflow should have dependency on main job") } - // Verify conditional execution using BuildSafeOutputType - if !strings.Contains(lockContentStr, "if: contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')") { + // Verify conditional execution using BuildSafeOutputType combined with pull request context + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')", + "github.event.issue.number", + "github.event.issue.pull_request", + "github.event.pull_request", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(lockContentStr, part) { + conditionFound = false + t.Logf("Missing condition part: %s", part) + break + } + } + if !conditionFound { t.Errorf("Generated workflow should have pull request context condition") } } @@ -280,8 +294,21 @@ This workflow has minimal push-to-pull-request-branch configuration. t.Errorf("Generated workflow should not contain target configuration when not specified") } - // Verify default conditional execution using BuildSafeOutputType - if !strings.Contains(lockContentStr, "if: contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')") { + // Verify default conditional execution using BuildSafeOutputType combined with pull request context + expectedConditionParts := []string{ + "contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')", + "github.event.issue.number", + "github.event.issue.pull_request", + "github.event.pull_request", + } + conditionFound := true + for _, part := range expectedConditionParts { + if !strings.Contains(lockContentStr, part) { + conditionFound = false + break + } + } + if !conditionFound { t.Errorf("Generated workflow should have default pull request context condition") } } diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 15a96f4199a..97fe8bc81f5 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -61,8 +61,30 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa "issue_url": "${{ steps.update_issue.outputs.issue_url }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("update-issue").Render() + // Determine the job condition based on target configuration + var baseCondition string + if data.SafeOutputs.UpdateIssues.Target == "*" { + // Allow updates to any issue - no specific context required + baseCondition = "always()" + } else if data.SafeOutputs.UpdateIssues.Target != "" { + // Explicit issue number specified - no specific context required + baseCondition = "always()" + } else { + // Default behavior: only update triggering issue + baseCondition = "github.event.issue.number" + } + + // Combine the base condition with the safe output type condition + var jobCondition string + safeOutputCondition := BuildSafeOutputType("update-issue").Render() + + if baseCondition == "always()" { + // If base condition is always(), just use the safe output condition + jobCondition = safeOutputCondition + } else { + // Combine both conditions with AND + jobCondition = fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + } job := &Job{ Name: "update_issue", From e92f392670624fcd2e8e70f528a087321e284d8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 22:33:16 +0000 Subject: [PATCH 04/18] Remove always() condition and use expression trees (AndNode/OrNode) for building conditions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 2 +- .github/workflows/tidy.lock.yml | 4 +-- pkg/workflow/add_comment.go | 33 +++++++++--------- pkg/workflow/add_labels.go | 17 +++++++--- pkg/workflow/create_discussion.go | 7 ++-- pkg/workflow/create_issue.go | 6 ++-- pkg/workflow/create_pr_review_comment.go | 22 ++++++++---- pkg/workflow/create_pull_request.go | 6 ++-- pkg/workflow/publish_assets.go | 7 ++-- pkg/workflow/push_to_pull_request_branch.go | 37 +++++++++++---------- pkg/workflow/update_issue.go | 33 +++++++++--------- 11 files changed, 94 insertions(+), 80 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 48ba59d59a2..6fb77b960ae 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2525,7 +2525,7 @@ jobs: create_issue_comment: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'add-comment')) && (github.event.issue.number || github.event.pull_request.number) + (contains(needs.agent.outputs.output_types, 'add-comment')) && ((github.event.issue.number) || (github.event.pull_request.number)) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index a3cc899d249..5b5afebd7f4 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -2445,8 +2445,8 @@ jobs: push_to_pull_request_branch: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && ((github.event.issue.number && - github.event.issue.pull_request) || github.event.pull_request) + (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index 0ae94d32d31..a70c1a366cb 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -57,31 +57,30 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam "comment_url": "${{ steps.add_comment.outputs.comment_url }}", } - // Determine the job condition based on target configuration - var baseCondition string + // Build the job condition using expression trees + safeOutputCondition := BuildSafeOutputType("add-comment") + + var jobCondition ConditionNode if data.SafeOutputs.AddComments.Target == "*" { // Allow the job to run in any context when target is "*" - baseCondition = "always()" // This allows the job to run even without triggering issue/PR - } else { - // Default behavior: only run in issue or PR context - baseCondition = "github.event.issue.number || github.event.pull_request.number" - } - - // Combine the base condition with the safe output type condition - var jobCondition string - safeOutputCondition := BuildSafeOutputType("add-comment").Render() - - if baseCondition == "always()" { - // If base condition is always(), just use the safe output condition + // Just use the safe output condition jobCondition = safeOutputCondition } else { - // Combine both conditions with AND - jobCondition = fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + // Default behavior: only run in issue or PR context + // Combine safe output condition AND (issue number OR PR number) + baseCondition := &OrNode{ + Left: &ExpressionNode{Expression: "github.event.issue.number"}, + Right: &ExpressionNode{Expression: "github.event.pull_request.number"}, + } + jobCondition = &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, + } } job := &Job{ Name: "create_issue_comment", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write\n pull-requests: write", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/add_labels.go b/pkg/workflow/add_labels.go index 42cd21593f6..debe462970c 100644 --- a/pkg/workflow/add_labels.go +++ b/pkg/workflow/add_labels.go @@ -71,14 +71,21 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str "labels_added": "${{ steps.add_labels.outputs.labels_added }}", } - // Combine the base condition with the safe output type condition - var baseCondition = "github.event.issue.number || github.event.pull_request.number" // Only run in issue or PR context - safeOutputCondition := BuildSafeOutputType("add-labels").Render() - jobCondition := fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + // Build the job condition using expression trees + // Combine safe output condition AND (issue number OR PR number) + safeOutputCondition := BuildSafeOutputType("add-labels") + baseCondition := &OrNode{ + Left: &ExpressionNode{Expression: "github.event.issue.number"}, + Right: &ExpressionNode{Expression: "github.event.pull_request.number"}, + } + jobCondition := &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, + } job := &Job{ Name: "add_labels", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write\n pull-requests: write", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index d0bf70acecc..26120d31187 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -90,13 +90,12 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam "discussion_url": "${{ steps.create_discussion.outputs.discussion_url }}", } - // Determine the job condition based on safe output type - safeOutputCondition := BuildSafeOutputType("create-discussion").Render() - jobCondition := safeOutputCondition + // Build the job condition using expression tree + jobCondition := BuildSafeOutputType("create-discussion") job := &Job{ Name: "create_discussion", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n discussions: write", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index e069d354459..ad8a385ba01 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -124,15 +124,15 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str "issue_url": "${{ steps.create_issue.outputs.issue_url }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("create-issue").Render() + // Build the job condition using expression tree + jobCondition := BuildSafeOutputType("create-issue") // Set base permissions permissions := "permissions:\n contents: read\n issues: write" job := &Job{ Name: "create_issue", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: permissions, TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/create_pr_review_comment.go b/pkg/workflow/create_pr_review_comment.go index 29df5554e5c..ba9efb47d74 100644 --- a/pkg/workflow/create_pr_review_comment.go +++ b/pkg/workflow/create_pr_review_comment.go @@ -52,16 +52,26 @@ func (c *Compiler) buildCreateOutputPullRequestReviewCommentJob(data *WorkflowDa "review_comment_url": "${{ steps.create_pr_review_comment.outputs.review_comment_url }}", } + // Build the job condition using expression trees // We only run in pull request context, Note that in pull request comments only github.event.issue.pull_request is set. - baseCondition := "(github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request" - - // Combine the base condition with the safe output type condition - safeOutputCondition := BuildSafeOutputType("create-pull-request-review-comment").Render() - jobCondition := fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + // Combine safe output condition AND ((issue.number AND issue.pull_request) OR pull_request) + safeOutputCondition := BuildSafeOutputType("create-pull-request-review-comment") + issueWithPR := &AndNode{ + Left: &ExpressionNode{Expression: "github.event.issue.number"}, + Right: &ExpressionNode{Expression: "github.event.issue.pull_request"}, + } + baseCondition := &OrNode{ + Left: issueWithPR, + Right: &ExpressionNode{Expression: "github.event.pull_request"}, + } + jobCondition := &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, + } job := &Job{ Name: "create_pr_review_comment", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n pull-requests: write", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 98f9e5e8baa..4f0c24e307d 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -107,12 +107,12 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa "fallback_used": "${{ steps.create_pull_request.outputs.fallback_used }}", } - // Determine the job condition for command workflows - jobCondition := BuildSafeOutputType("create-pull-request").Render() + // Build the job condition using expression tree + jobCondition := BuildSafeOutputType("create-pull-request") job := &Job{ Name: "create_pull_request", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: write\n issues: write\n pull-requests: write", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 02148af6ddf..8aa80bfc3f9 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -165,16 +165,15 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, "branch_name": "${{ steps.upload_assets.outputs.branch_name }}", } - // Determine the job condition based on safe output type - safeOutputCondition := BuildSafeOutputType("publish-assets").Render() - jobCondition := safeOutputCondition + // Build the job condition using expression tree + jobCondition := BuildSafeOutputType("publish-assets") // Set base permissions permissions := "permissions:\n contents: write # Required for creating orphaned branch and pushing assets" job := &Job{ Name: "upload_assets", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: permissions, TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index a9052943e5a..5520235e25c 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -95,31 +95,34 @@ func (c *Compiler) buildCreateOutputPushToPullRequestBranchJob(data *WorkflowDat "push_url": "${{ steps.push_to_pull_request_branch.outputs.push_url }}", } - // Determine the job condition based on target configuration - var baseCondition string + // Build the job condition using expression trees + safeOutputCondition := BuildSafeOutputType("push-to-pull-request-branch") + + var jobCondition ConditionNode if data.SafeOutputs.PushToPullRequestBranch.Target == "*" { // Allow pushing to any pull request - no specific context required - baseCondition = "always()" - } else { - // Default behavior: only run in pull request context, or issue context with a linked PR - baseCondition = "(github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request" - } - - // Combine the base condition with the safe output type condition - var jobCondition string - safeOutputCondition := BuildSafeOutputType("push-to-pull-request-branch").Render() - - if baseCondition == "always()" { - // If base condition is always(), just use the safe output condition + // Just use the safe output condition jobCondition = safeOutputCondition } else { - // Combine both conditions with AND - jobCondition = fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + // Default behavior: only run in pull request context, or issue context with a linked PR + // Combine safe output condition AND ((issue.number AND issue.pull_request) OR pull_request) + issueWithPR := &AndNode{ + Left: &ExpressionNode{Expression: "github.event.issue.number"}, + Right: &ExpressionNode{Expression: "github.event.issue.pull_request"}, + } + baseCondition := &OrNode{ + Left: issueWithPR, + Right: &ExpressionNode{Expression: "github.event.pull_request"}, + } + jobCondition = &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, + } } job := &Job{ Name: "push_to_pull_request_branch", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: write\n pull-requests: read\n issues: read", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 97fe8bc81f5..78dc9dbe9d8 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -61,34 +61,31 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa "issue_url": "${{ steps.update_issue.outputs.issue_url }}", } - // Determine the job condition based on target configuration - var baseCondition string + // Build the job condition using expression trees + safeOutputCondition := BuildSafeOutputType("update-issue") + + var jobCondition ConditionNode if data.SafeOutputs.UpdateIssues.Target == "*" { // Allow updates to any issue - no specific context required - baseCondition = "always()" + // Just use the safe output condition + jobCondition = safeOutputCondition } else if data.SafeOutputs.UpdateIssues.Target != "" { // Explicit issue number specified - no specific context required - baseCondition = "always()" - } else { - // Default behavior: only update triggering issue - baseCondition = "github.event.issue.number" - } - - // Combine the base condition with the safe output type condition - var jobCondition string - safeOutputCondition := BuildSafeOutputType("update-issue").Render() - - if baseCondition == "always()" { - // If base condition is always(), just use the safe output condition + // Just use the safe output condition jobCondition = safeOutputCondition } else { - // Combine both conditions with AND - jobCondition = fmt.Sprintf("(%s) && (%s)", safeOutputCondition, baseCondition) + // Default behavior: only update triggering issue + // Combine safe output condition AND issue number exists + baseCondition := &ExpressionNode{Expression: "github.event.issue.number"} + jobCondition = &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, + } } job := &Job{ Name: "update_issue", - If: jobCondition, + If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write", TimeoutMinutes: 10, // 10-minute timeout as required From 0eb1432e1ab0abf2431efa4dd7145d698f540db2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 22:44:19 +0000 Subject: [PATCH 05/18] Remove Target="*" special case handling - always combine with base condition Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/add_comment.go | 25 ++++++---------- pkg/workflow/push_to_pull_request_branch.go | 33 ++++++++------------- pkg/workflow/update_issue.go | 23 ++++---------- 3 files changed, 28 insertions(+), 53 deletions(-) diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index a70c1a366cb..e3c24667f2c 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -58,24 +58,17 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam } // Build the job condition using expression trees + // Always run in issue or PR context + // Combine safe output condition AND (issue number OR PR number) safeOutputCondition := BuildSafeOutputType("add-comment") - var jobCondition ConditionNode - if data.SafeOutputs.AddComments.Target == "*" { - // Allow the job to run in any context when target is "*" - // Just use the safe output condition - jobCondition = safeOutputCondition - } else { - // Default behavior: only run in issue or PR context - // Combine safe output condition AND (issue number OR PR number) - baseCondition := &OrNode{ - Left: &ExpressionNode{Expression: "github.event.issue.number"}, - Right: &ExpressionNode{Expression: "github.event.pull_request.number"}, - } - jobCondition = &AndNode{ - Left: safeOutputCondition, - Right: baseCondition, - } + baseCondition := &OrNode{ + Left: &ExpressionNode{Expression: "github.event.issue.number"}, + Right: &ExpressionNode{Expression: "github.event.pull_request.number"}, + } + jobCondition := &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, } job := &Job{ diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index 5520235e25c..7f6905bcb4d 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -96,28 +96,21 @@ func (c *Compiler) buildCreateOutputPushToPullRequestBranchJob(data *WorkflowDat } // Build the job condition using expression trees + // Always run in pull request context, or issue context with a linked PR + // Combine safe output condition AND ((issue.number AND issue.pull_request) OR pull_request) safeOutputCondition := BuildSafeOutputType("push-to-pull-request-branch") - var jobCondition ConditionNode - if data.SafeOutputs.PushToPullRequestBranch.Target == "*" { - // Allow pushing to any pull request - no specific context required - // Just use the safe output condition - jobCondition = safeOutputCondition - } else { - // Default behavior: only run in pull request context, or issue context with a linked PR - // Combine safe output condition AND ((issue.number AND issue.pull_request) OR pull_request) - issueWithPR := &AndNode{ - Left: &ExpressionNode{Expression: "github.event.issue.number"}, - Right: &ExpressionNode{Expression: "github.event.issue.pull_request"}, - } - baseCondition := &OrNode{ - Left: issueWithPR, - Right: &ExpressionNode{Expression: "github.event.pull_request"}, - } - jobCondition = &AndNode{ - Left: safeOutputCondition, - Right: baseCondition, - } + issueWithPR := &AndNode{ + Left: &ExpressionNode{Expression: "github.event.issue.number"}, + Right: &ExpressionNode{Expression: "github.event.issue.pull_request"}, + } + baseCondition := &OrNode{ + Left: issueWithPR, + Right: &ExpressionNode{Expression: "github.event.pull_request"}, + } + jobCondition := &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, } job := &Job{ diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 78dc9dbe9d8..3cb0ed038d0 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -62,25 +62,14 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa } // Build the job condition using expression trees + // Always require issue number context + // Combine safe output condition AND issue number exists safeOutputCondition := BuildSafeOutputType("update-issue") - var jobCondition ConditionNode - if data.SafeOutputs.UpdateIssues.Target == "*" { - // Allow updates to any issue - no specific context required - // Just use the safe output condition - jobCondition = safeOutputCondition - } else if data.SafeOutputs.UpdateIssues.Target != "" { - // Explicit issue number specified - no specific context required - // Just use the safe output condition - jobCondition = safeOutputCondition - } else { - // Default behavior: only update triggering issue - // Combine safe output condition AND issue number exists - baseCondition := &ExpressionNode{Expression: "github.event.issue.number"} - jobCondition = &AndNode{ - Left: safeOutputCondition, - Right: baseCondition, - } + baseCondition := &ExpressionNode{Expression: "github.event.issue.number"} + jobCondition := &AndNode{ + Left: safeOutputCondition, + Right: baseCondition, } job := &Job{ From 24695c4248f6dc592427fab22a6f058fcbb948a9 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Mon, 29 Sep 2025 22:48:43 +0000 Subject: [PATCH 06/18] Refactor job condition handling to simplify expression tree usage across multiple workflows --- pkg/workflow/add_labels.go | 2 -- pkg/workflow/create_code_scanning_alert.go | 4 +--- pkg/workflow/create_discussion.go | 1 - pkg/workflow/create_issue.go | 1 - pkg/workflow/create_pr_review_comment.go | 3 --- 5 files changed, 1 insertion(+), 10 deletions(-) diff --git a/pkg/workflow/add_labels.go b/pkg/workflow/add_labels.go index debe462970c..d3885fa8bdf 100644 --- a/pkg/workflow/add_labels.go +++ b/pkg/workflow/add_labels.go @@ -71,8 +71,6 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str "labels_added": "${{ steps.add_labels.outputs.labels_added }}", } - // Build the job condition using expression trees - // Combine safe output condition AND (issue number OR PR number) safeOutputCondition := BuildSafeOutputType("add-labels") baseCondition := &OrNode{ Left: &ExpressionNode{Expression: "github.event.issue.number"}, diff --git a/pkg/workflow/create_code_scanning_alert.go b/pkg/workflow/create_code_scanning_alert.go index 50e9031d29a..04f410988aa 100644 --- a/pkg/workflow/create_code_scanning_alert.go +++ b/pkg/workflow/create_code_scanning_alert.go @@ -81,9 +81,7 @@ func (c *Compiler) buildCreateOutputCodeScanningAlertJob(data *WorkflowData, mai "codeql_uploaded": "${{ steps.create_code_scanning_alert.outputs.codeql_uploaded }}", } - // Build job condition - repository security advisories can run in any context unlike PR review comments - safeOutputCondition := BuildSafeOutputType("create-code-scanning-alert").Render() - jobCondition := safeOutputCondition + jobCondition := BuildSafeOutputType("create-code-scanning-alert").Render() job := &Job{ Name: "create_code_scanning_alert", diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index 26120d31187..3618584c02c 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -90,7 +90,6 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam "discussion_url": "${{ steps.create_discussion.outputs.discussion_url }}", } - // Build the job condition using expression tree jobCondition := BuildSafeOutputType("create-discussion") job := &Job{ diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index ad8a385ba01..267a26c5921 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -124,7 +124,6 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str "issue_url": "${{ steps.create_issue.outputs.issue_url }}", } - // Build the job condition using expression tree jobCondition := BuildSafeOutputType("create-issue") // Set base permissions diff --git a/pkg/workflow/create_pr_review_comment.go b/pkg/workflow/create_pr_review_comment.go index ba9efb47d74..b70e2f9cc6b 100644 --- a/pkg/workflow/create_pr_review_comment.go +++ b/pkg/workflow/create_pr_review_comment.go @@ -52,9 +52,6 @@ func (c *Compiler) buildCreateOutputPullRequestReviewCommentJob(data *WorkflowDa "review_comment_url": "${{ steps.create_pr_review_comment.outputs.review_comment_url }}", } - // Build the job condition using expression trees - // We only run in pull request context, Note that in pull request comments only github.event.issue.pull_request is set. - // Combine safe output condition AND ((issue.number AND issue.pull_request) OR pull_request) safeOutputCondition := BuildSafeOutputType("create-pull-request-review-comment") issueWithPR := &AndNode{ Left: &ExpressionNode{Expression: "github.event.issue.number"}, From 0ce8e41fb0b6fd25536cba1cd5d9799b72afcfea Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Mon, 29 Sep 2025 22:51:46 +0000 Subject: [PATCH 07/18] Remove redundant comments about job condition building in workflow files --- pkg/workflow/create_pull_request.go | 1 - pkg/workflow/push_to_pull_request_branch.go | 4 ---- pkg/workflow/update_issue.go | 3 --- 3 files changed, 8 deletions(-) diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 4f0c24e307d..69a547e2901 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -107,7 +107,6 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa "fallback_used": "${{ steps.create_pull_request.outputs.fallback_used }}", } - // Build the job condition using expression tree jobCondition := BuildSafeOutputType("create-pull-request") job := &Job{ diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index 7f6905bcb4d..f6ce52974a3 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -95,11 +95,7 @@ func (c *Compiler) buildCreateOutputPushToPullRequestBranchJob(data *WorkflowDat "push_url": "${{ steps.push_to_pull_request_branch.outputs.push_url }}", } - // Build the job condition using expression trees - // Always run in pull request context, or issue context with a linked PR - // Combine safe output condition AND ((issue.number AND issue.pull_request) OR pull_request) safeOutputCondition := BuildSafeOutputType("push-to-pull-request-branch") - issueWithPR := &AndNode{ Left: &ExpressionNode{Expression: "github.event.issue.number"}, Right: &ExpressionNode{Expression: "github.event.issue.pull_request"}, diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 3cb0ed038d0..c6e0e65daf3 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -61,9 +61,6 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa "issue_url": "${{ steps.update_issue.outputs.issue_url }}", } - // Build the job condition using expression trees - // Always require issue number context - // Combine safe output condition AND issue number exists safeOutputCondition := BuildSafeOutputType("update-issue") baseCondition := &ExpressionNode{Expression: "github.event.issue.number"} From b8be7ef371e5c557f809978a8bda7b051d592f61 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Mon, 29 Sep 2025 22:55:43 +0000 Subject: [PATCH 08/18] Rename create_issue_comment job to add_comments and update conditions for consistency across workflows --- .github/workflows/ci-doctor.lock.yml | 2 +- pkg/cli/workflows/test-all.lock.yml | 18 ++++++++++++------ .../test-claude-patch-size-exceeded.lock.yml | 4 +++- ...test-claude-playwright-screenshots.lock.yml | 1 + .../test-copilot-patch-size-exceeded.lock.yml | 4 +++- ...est-copilot-playwright-screenshots.lock.yml | 1 + pkg/workflow/add_comment.go | 4 ++-- pkg/workflow/compiler.go | 6 +++--- 8 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 6fb77b960ae..253281a1681 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2522,7 +2522,7 @@ jobs: await main(); })(); - create_issue_comment: + add_comments: needs: agent if: > (contains(needs.agent.outputs.output_types, 'add-comment')) && ((github.event.issue.number) || (github.event.pull_request.number)) diff --git a/pkg/cli/workflows/test-all.lock.yml b/pkg/cli/workflows/test-all.lock.yml index b196c63797a..65be9db62fb 100644 --- a/pkg/cli/workflows/test-all.lock.yml +++ b/pkg/cli/workflows/test-all.lock.yml @@ -3068,9 +3068,10 @@ jobs: await main(); })(); - create_issue_comment: + add_comments: needs: agent - if: always() + if: > + (contains(needs.agent.outputs.output_types, 'add-comment')) && ((github.event.issue.number) || (github.event.pull_request.number)) runs-on: ubuntu-latest permissions: contents: read @@ -3236,7 +3237,9 @@ jobs: create_pr_review_comment: needs: agent - if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request + if: > + (contains(needs.agent.outputs.output_types, 'create-pull-request-review-comment')) && (((github.event.issue.number) && + (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: contents: read @@ -3760,7 +3763,8 @@ jobs: add_labels: needs: agent - if: github.event.issue.number || github.event.pull_request.number + if: > + (contains(needs.agent.outputs.output_types, 'add-labels')) && ((github.event.issue.number) || (github.event.pull_request.number)) runs-on: ubuntu-latest permissions: contents: read @@ -3957,7 +3961,7 @@ jobs: update_issue: needs: agent - if: always() + if: (contains(needs.agent.outputs.output_types, 'update-issue')) && (github.event.issue.number) runs-on: ubuntu-latest permissions: contents: read @@ -4150,7 +4154,9 @@ jobs: push_to_pull_request_branch: needs: agent - if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request + if: > + (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml index dc9fa41a745..f59a868bf4d 100644 --- a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml @@ -2384,7 +2384,9 @@ jobs: push_to_pull_request_branch: needs: agent - if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request + if: > + (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml b/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml index d4bb647a091..2dd5f468776 100644 --- a/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml +++ b/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml @@ -2642,6 +2642,7 @@ jobs: upload_assets: needs: agent + if: contains(needs.agent.outputs.output_types, 'publish-assets') runs-on: ubuntu-latest permissions: contents: write # Required for creating orphaned branch and pushing assets diff --git a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml index 54005eb8d9b..076144bf9df 100644 --- a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml @@ -1940,7 +1940,9 @@ jobs: push_to_pull_request_branch: needs: agent - if: (github.event.issue.number && github.event.issue.pull_request) || github.event.pull_request + if: > + (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml b/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml index 6c5896758e8..c93b621ecf3 100644 --- a/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml +++ b/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml @@ -2170,6 +2170,7 @@ jobs: upload_assets: needs: agent + if: contains(needs.agent.outputs.output_types, 'publish-assets') runs-on: ubuntu-latest permissions: contents: write # Required for creating orphaned branch and pushing assets diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index e3c24667f2c..e84c95a1d45 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -15,7 +15,7 @@ type AddCommentsConfig struct { Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any issue), or explicit issue number } -// buildCreateOutputAddCommentJob creates the create_issue_comment job +// buildCreateOutputAddCommentJob creates the add_comments job func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobName string) (*Job, error) { if data.SafeOutputs == nil || data.SafeOutputs.AddComments == nil { return nil, fmt.Errorf("safe-outputs.add-comment configuration is required") @@ -72,7 +72,7 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam } job := &Job{ - Name: "create_issue_comment", + Name: "add_comments", If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write\n pull-requests: write", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 8d0a91b4ba4..8acaa787b85 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -1325,14 +1325,14 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName string, task } } - // Build create_issue_comment job if output.add-comment is configured + // Build add_comments job if output.add-comment is configured if data.SafeOutputs.AddComments != nil { createCommentJob, err := c.buildCreateOutputAddCommentJob(data, jobName) if err != nil { - return fmt.Errorf("failed to build create_issue_comment job: %w", err) + return fmt.Errorf("failed to build add_comments job: %w", err) } if err := c.jobManager.AddJob(createCommentJob); err != nil { - return fmt.Errorf("failed to add create_issue_comment job: %w", err) + return fmt.Errorf("failed to add add_comments job: %w", err) } } From 439758fb6b7885cde9feca8474f5255074648b9d Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 30 Sep 2025 00:26:44 +0000 Subject: [PATCH 09/18] Rename add_comments job to add_comment and simplify job conditions in workflows --- .github/workflows/ci-doctor.lock.yml | 2 +- pkg/cli/workflows/test-all.lock.yml | 7 +++---- pkg/workflow/add_comment.go | 23 +++++++++-------------- pkg/workflow/compiler.go | 6 +++--- pkg/workflow/expressions.go | 8 ++++++++ pkg/workflow/update_issue.go | 10 ++++------ 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 253281a1681..c49f5b12d16 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2522,7 +2522,7 @@ jobs: await main(); })(); - add_comments: + add_comment: needs: agent if: > (contains(needs.agent.outputs.output_types, 'add-comment')) && ((github.event.issue.number) || (github.event.pull_request.number)) diff --git a/pkg/cli/workflows/test-all.lock.yml b/pkg/cli/workflows/test-all.lock.yml index 65be9db62fb..8aab0b799b4 100644 --- a/pkg/cli/workflows/test-all.lock.yml +++ b/pkg/cli/workflows/test-all.lock.yml @@ -3068,10 +3068,9 @@ jobs: await main(); })(); - add_comments: + add_comment: needs: agent - if: > - (contains(needs.agent.outputs.output_types, 'add-comment')) && ((github.event.issue.number) || (github.event.pull_request.number)) + if: contains(needs.agent.outputs.output_types, 'add-comment') runs-on: ubuntu-latest permissions: contents: read @@ -3961,7 +3960,7 @@ jobs: update_issue: needs: agent - if: (contains(needs.agent.outputs.output_types, 'update-issue')) && (github.event.issue.number) + if: contains(needs.agent.outputs.output_types, 'update-issue') runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index e84c95a1d45..16785e56ac6 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -15,7 +15,7 @@ type AddCommentsConfig struct { Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any issue), or explicit issue number } -// buildCreateOutputAddCommentJob creates the add_comments job +// buildCreateOutputAddCommentJob creates the add_comment job func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobName string) (*Job, error) { if data.SafeOutputs == nil || data.SafeOutputs.AddComments == nil { return nil, fmt.Errorf("safe-outputs.add-comment configuration is required") @@ -57,22 +57,17 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam "comment_url": "${{ steps.add_comment.outputs.comment_url }}", } - // Build the job condition using expression trees - // Always run in issue or PR context - // Combine safe output condition AND (issue number OR PR number) - safeOutputCondition := BuildSafeOutputType("add-comment") - - baseCondition := &OrNode{ - Left: &ExpressionNode{Expression: "github.event.issue.number"}, - Right: &ExpressionNode{Expression: "github.event.pull_request.number"}, - } - jobCondition := &AndNode{ - Left: safeOutputCondition, - Right: baseCondition, + var jobCondition = BuildSafeOutputType("add-comment") + if data.SafeOutputs.AddComments.Target == "" { + eventCondition := buildOr( + BuildPropertyAccess("github.event.issue.number"), + BuildPropertyAccess("github.event.pull_request.number"), + ) + jobCondition = buildAnd(jobCondition, eventCondition) } job := &Job{ - Name: "add_comments", + Name: "add_comment", If: jobCondition.Render(), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write\n pull-requests: write", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 8acaa787b85..378c5e43d64 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -1325,14 +1325,14 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName string, task } } - // Build add_comments job if output.add-comment is configured + // Build add_comment job if output.add-comment is configured if data.SafeOutputs.AddComments != nil { createCommentJob, err := c.buildCreateOutputAddCommentJob(data, jobName) if err != nil { - return fmt.Errorf("failed to build add_comments job: %w", err) + return fmt.Errorf("failed to build add_comment job: %w", err) } if err := c.jobManager.AddJob(createCommentJob); err != nil { - return fmt.Errorf("failed to add add_comments job: %w", err) + return fmt.Errorf("failed to add add_comment job: %w", err) } } diff --git a/pkg/workflow/expressions.go b/pkg/workflow/expressions.go index ddf26c0cd9f..28ec0fdc967 100644 --- a/pkg/workflow/expressions.go +++ b/pkg/workflow/expressions.go @@ -205,6 +205,14 @@ func buildConditionTree(existingCondition string, draftCondition string) Conditi return &AndNode{Left: existingNode, Right: draftNode} } +func buildOr(left ConditionNode, right ConditionNode) ConditionNode { + return &OrNode{Left: left, Right: right} +} + +func buildAnd(left ConditionNode, right ConditionNode) ConditionNode { + return &AndNode{Left: left, Right: right} +} + // buildReactionCondition creates a condition tree for the add_reaction job func buildReactionCondition() ConditionNode { // Build a list of event types that should trigger reactions using the new expression nodes diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index c6e0e65daf3..f3df8517e78 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -61,12 +61,10 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa "issue_url": "${{ steps.update_issue.outputs.issue_url }}", } - safeOutputCondition := BuildSafeOutputType("update-issue") - - baseCondition := &ExpressionNode{Expression: "github.event.issue.number"} - jobCondition := &AndNode{ - Left: safeOutputCondition, - Right: baseCondition, + var jobCondition = BuildSafeOutputType("update-issue") + if data.SafeOutputs.UpdateIssues.Target == "" { + eventCondition := BuildPropertyAccess("github.event.issue.number") + jobCondition = buildAnd(jobCondition, eventCondition) } job := &Job{ From 90f839388739fc91359d030b1e64298087f796be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:38:37 +0000 Subject: [PATCH 10/18] Fix tests: update job name from create_issue_comment to add_comment Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compile_test.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/workflow/compile_test.go b/pkg/workflow/compile_test.go index 93981b0af47..cb51a90dd4f 100644 --- a/pkg/workflow/compile_test.go +++ b/pkg/workflow/compile_test.go @@ -529,7 +529,7 @@ safe-outputs: # Test Output Issue Comment Job Generation -This workflow tests the create_issue_comment job generation. +This workflow tests the add_comment job generation. ` testFile := filepath.Join(tmpDir, "test-output-issue-comment.md") @@ -554,23 +554,23 @@ This workflow tests the create_issue_comment job generation. lockContent := string(content) - // Verify create_issue_comment job exists - if !strings.Contains(lockContent, "create_issue_comment:") { - t.Error("Expected 'create_issue_comment' job to be in generated workflow") + // Verify add_comment job exists + if !strings.Contains(lockContent, "add_comment:") { + t.Error("Expected 'add_comment' job to be in generated workflow") } // Verify job properties if !strings.Contains(lockContent, "timeout-minutes: 10") { - t.Error("Expected 10-minute timeout in create_issue_comment job") + t.Error("Expected 10-minute timeout in add_comment job") } if !strings.Contains(lockContent, "permissions:\n contents: read\n issues: write\n pull-requests: write") { - t.Error("Expected correct permissions in create_issue_comment job") + t.Error("Expected correct permissions in add_comment job") } // Verify the job uses github-script if !strings.Contains(lockContent, "uses: actions/github-script@v8") { - t.Error("Expected github-script action to be used in create_issue_comment job") + t.Error("Expected github-script action to be used in add_comment job") } // Verify job has conditional execution using BuildSafeOutputType combined with base condition @@ -587,12 +587,12 @@ This workflow tests the create_issue_comment job generation. } } if !conditionFound { - t.Error("Expected create_issue_comment job to have conditional execution") + t.Error("Expected add_comment job to have conditional execution") } // Verify job dependencies if !strings.Contains(lockContent, "needs: agent") { - t.Error("Expected create_issue_comment job to depend on main job") + t.Error("Expected add_comment job to depend on main job") } // Verify JavaScript content includes environment variable for agent output @@ -650,9 +650,9 @@ This workflow tests that issue comment job is skipped for non-issue/PR events. lockContent := string(content) - // Verify create_issue_comment job exists (it should be generated regardless of trigger) - if !strings.Contains(lockContent, "create_issue_comment:") { - t.Error("Expected 'create_issue_comment' job to be in generated workflow") + // Verify add_comment job exists (it should be generated regardless of trigger) + if !strings.Contains(lockContent, "add_comment:") { + t.Error("Expected 'add_comment' job to be in generated workflow") } // Verify job has conditional execution using BuildSafeOutputType combined with base condition @@ -669,7 +669,7 @@ This workflow tests that issue comment job is skipped for non-issue/PR events. } } if !conditionFound { - t.Error("Expected create_issue_comment job to have conditional execution for skipping") + t.Error("Expected add_comment job to have conditional execution for skipping") } // t.Logf("Generated workflow content:\n%s", lockContent) From 60205df3ba948164fbbc19296c1707dc6a8c7b97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 01:50:35 +0000 Subject: [PATCH 11/18] Add Target support to add-labels similar to update-issue Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 4 ++++ pkg/workflow/add_labels.go | 21 ++++++++++++-------- pkg/workflow/safe_outputs.go | 7 +++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 9ca4299202c..1fad335b6e6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1574,6 +1574,10 @@ "description": "Minimum number of labels to add (default: 0 - no requirement)", "minimum": 0 }, + "target": { + "type": "string", + "description": "Target for labels: 'triggering' (default), '*' (any issue/PR), or explicit issue/PR number" + }, "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_labels.go b/pkg/workflow/add_labels.go index 5fa29d1311d..08c896e6bf3 100644 --- a/pkg/workflow/add_labels.go +++ b/pkg/workflow/add_labels.go @@ -11,6 +11,7 @@ type AddLabelsConfig struct { MaxCount *int `yaml:"max,omitempty"` // Optional maximum number of labels to add (default: 3) MinCount *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 } // buildCreateOutputLabelJob creates the add_labels job @@ -45,6 +46,11 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str // Pass the max limit 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 != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_LABELS_TARGET: %q\n", data.SafeOutputs.AddLabels.Target)) + } + // Pass the staged flag if it's set to true if data.SafeOutputs.Staged != nil && *data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") @@ -71,14 +77,13 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str "labels_added": "${{ steps.add_labels.outputs.labels_added }}", } - safeOutputCondition := BuildSafeOutputType("add-labels") - baseCondition := &OrNode{ - Left: &ExpressionNode{Expression: "github.event.issue.number"}, - Right: &ExpressionNode{Expression: "github.event.pull_request.number"}, - } - jobCondition := &AndNode{ - Left: safeOutputCondition, - Right: baseCondition, + var jobCondition = BuildSafeOutputType("add-labels") + if data.SafeOutputs.AddLabels == nil || data.SafeOutputs.AddLabels.Target == "" { + eventCondition := buildOr( + BuildPropertyAccess("github.event.issue.number"), + BuildPropertyAccess("github.event.pull_request.number"), + ) + jobCondition = buildAnd(jobCondition, eventCondition) } job := &Job{ diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index 7015fcc73f2..00d4857c807 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -328,6 +328,13 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Parse target + if target, exists := labelsMap["target"]; exists { + if targetStr, ok := target.(string); ok { + labelConfig.Target = targetStr + } + } + config.AddLabels = labelConfig } else if labels == nil { // Handle null case: create empty config (allows any labels) From efeae1161f031000be501170256db4d073bdb850 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 02:00:38 +0000 Subject: [PATCH 12/18] Fix publish_assets.go to use correct output type "upload-asset" Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/publish_assets.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 49fb6f51a9c..75a04b1243d 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -166,7 +166,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, } // Build the job condition using expression tree - jobCondition := BuildSafeOutputType("publish-assets") + jobCondition := BuildSafeOutputType("upload-asset") // Set base permissions permissions := "permissions:\n contents: write # Required for creating orphaned branch and pushing assets" From 3b4a88b68620c1cfb67f4e39bf8f9371b6c3134d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 02:33:46 +0000 Subject: [PATCH 13/18] Add Target support to add_labels.js JavaScript implementation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/js/add_labels.js | 61 +++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index 78cbb65870e..76e1ddfc66c 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -74,34 +74,69 @@ async function main() { return; } core.debug(`Max count: ${maxCount}`); + + // Get the target configuration from environment variable + const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; + core.info(`Labels target configuration: ${labelsTarget}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - if (!isIssueContext && !isPRContext) { - core.setFailed("Not running in issue or pull request context, skipping label addition"); + + // Validate context based on target configuration + if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { + core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); return; } + + // Determine the issue/PR number based on target configuration let issueNumber; let contextType; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; + + if (labelsTarget === "*") { + // For target "*", we need an explicit issue number from the labels item + if (labelsItem.issue_number) { + issueNumber = parseInt(labelsItem.issue_number, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + return; + } + contextType = "issue/PR"; } else { - core.setFailed("Issue context detected but no issue found in payload"); + core.setFailed('Target is "*" but no issue_number specified in labels item'); return; } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - core.setFailed("Pull request context detected but no pull request found in payload"); + } else if (labelsTarget && labelsTarget !== "triggering") { + // Explicit issue number specified in target + issueNumber = parseInt(labelsTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); return; } + contextType = "issue/PR"; + } else { + // Default behavior: use triggering issue/PR + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } } + if (!issueNumber) { core.setFailed("Could not determine issue or pull request number"); return; From 3dc2e95be72cc6d8cd9c1195336f91ebdd6305e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 04:46:57 +0000 Subject: [PATCH 14/18] Add nil checks before accessing Target field in add_comment and update_issue Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/add_comment.go | 2 +- pkg/workflow/update_issue.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index c3546723847..435b58ceda8 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -58,7 +58,7 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam } var jobCondition = BuildSafeOutputType("add-comment") - if data.SafeOutputs.AddComments.Target == "" { + if data.SafeOutputs.AddComments != nil && data.SafeOutputs.AddComments.Target == "" { eventCondition := buildOr( BuildPropertyAccess("github.event.issue.number"), BuildPropertyAccess("github.event.pull_request.number"), diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 529495a8610..ff129f49d4a 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -62,7 +62,7 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa } var jobCondition = BuildSafeOutputType("update-issue") - if data.SafeOutputs.UpdateIssues.Target == "" { + if data.SafeOutputs.UpdateIssues != nil && data.SafeOutputs.UpdateIssues.Target == "" { eventCondition := BuildPropertyAccess("github.event.issue.number") jobCondition = buildAnd(jobCondition, eventCondition) } From 995b2d0c7c50535dde2983ae0a74cba38d370436 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:38:38 +0000 Subject: [PATCH 15/18] Wrap BuildSafeOutputType conditions with always() to run even when agent fails Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compile_test.go | 15 +- pkg/workflow/create_pr_review_comment_test.go | 3 +- pkg/workflow/expressions.go | 7 +- pkg/workflow/js/add_labels.js | 358 ++--- pkg/workflow/js/collect_ndjson_output.js | 1415 +++++++++-------- pkg/workflow/js/create_discussion.js | 271 ++-- pkg/workflow/js/create_issue.js | 289 ++-- .../push_to_pull_request_branch_test.go | 2 + 8 files changed, 1170 insertions(+), 1190 deletions(-) diff --git a/pkg/workflow/compile_test.go b/pkg/workflow/compile_test.go index cb51a90dd4f..489f49496f8 100644 --- a/pkg/workflow/compile_test.go +++ b/pkg/workflow/compile_test.go @@ -575,6 +575,7 @@ This workflow tests the add_comment job generation. // Verify job has conditional execution using BuildSafeOutputType combined with base condition expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'add-comment')", "github.event.issue.number", "github.event.pull_request.number", @@ -587,7 +588,7 @@ This workflow tests the add_comment job generation. } } if !conditionFound { - t.Error("Expected add_comment job to have conditional execution") + t.Error("Expected add_comment job to have conditional execution with always()") } // Verify job dependencies @@ -657,6 +658,7 @@ This workflow tests that issue comment job is skipped for non-issue/PR events. // Verify job has conditional execution using BuildSafeOutputType combined with base condition expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'add-comment')", "github.event.issue.number", "github.event.pull_request.number", @@ -669,7 +671,7 @@ This workflow tests that issue comment job is skipped for non-issue/PR events. } } if !conditionFound { - t.Error("Expected add_comment job to have conditional execution for skipping") + t.Error("Expected add_comment job to have conditional execution with always() for skipping") } // t.Logf("Generated workflow content:\n%s", lockContent) @@ -1142,6 +1144,7 @@ This workflow tests the add_labels job generation. // Verify job has conditional execution using BuildSafeOutputType combined with base condition expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'add-labels')", "github.event.issue.number", "github.event.pull_request.number", @@ -1154,7 +1157,7 @@ This workflow tests the add_labels job generation. } } if !conditionFound { - t.Error("Expected add_labels job to have conditional execution") + t.Error("Expected add_labels job to have conditional execution with always()") } if !strings.Contains(lockContent, "needs: agent") { t.Error("Expected add_labels job to depend on main job") @@ -1228,6 +1231,7 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. // Verify job has conditional execution using BuildSafeOutputType combined with base condition expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'add-labels')", "github.event.issue.number", "github.event.pull_request.number", @@ -1240,7 +1244,7 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. } } if !conditionFound { - t.Error("Expected add_labels job to have conditional execution") + t.Error("Expected add_labels job to have conditional execution with always()") } // Verify JavaScript content includes environment variables for configuration @@ -1316,6 +1320,7 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. // Verify job has conditional execution using BuildSafeOutputType combined with base condition expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'add-labels')", "github.event.issue.number", "github.event.pull_request.number", @@ -1328,7 +1333,7 @@ Write your labels to ${{ env.GITHUB_AW_SAFE_OUTPUTS }}, one per line. } } if !conditionFound { - t.Error("Expected add_labels job to have conditional execution") + t.Error("Expected add_labels job to have conditional execution with always()") } // Verify JavaScript content includes environment variables for configuration diff --git a/pkg/workflow/create_pr_review_comment_test.go b/pkg/workflow/create_pr_review_comment_test.go index 873bd981064..e2725ee7a0e 100644 --- a/pkg/workflow/create_pr_review_comment_test.go +++ b/pkg/workflow/create_pr_review_comment_test.go @@ -244,6 +244,7 @@ This workflow tests job generation for PR review comments. // Verify job condition uses BuildSafeOutputType combined with pull request context expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'create-pull-request-review-comment')", "github.event.issue.number", "github.event.issue.pull_request", @@ -257,7 +258,7 @@ This workflow tests job generation for PR review comments. } } if !conditionFound { - t.Error("Expected job condition to check for pull request context") + t.Error("Expected job condition to check for pull request context with always()") } // Verify correct permissions are set diff --git a/pkg/workflow/expressions.go b/pkg/workflow/expressions.go index 28ec0fdc967..bd9eef522f3 100644 --- a/pkg/workflow/expressions.go +++ b/pkg/workflow/expressions.go @@ -313,10 +313,15 @@ func BuildNotFromFork() *ComparisonNode { } func BuildSafeOutputType(outputType string) ConditionNode { - return BuildFunctionCall("contains", + alwaysFunc := BuildFunctionCall("always") + containsFunc := BuildFunctionCall("contains", BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.output_types", constants.AgentJobName)), BuildStringLiteral(outputType), ) + return &AndNode{ + Left: alwaysFunc, + Right: containsFunc, + } } // BuildFromAllowedForks creates a condition to check if a pull request is from an allowed fork diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index 76e1ddfc66c..17af6d0b7df 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -1,212 +1,178 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - - // Get the target configuration from environment variable - const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; - core.info(`Labels target configuration: ${labelsTarget}`); - - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - - // Validate context based on target configuration - if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { - core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); - return; - } - - // Determine the issue/PR number based on target configuration - let issueNumber; - let contextType; - - if (labelsTarget === "*") { - // For target "*", we need an explicit issue number from the labels item - if (labelsItem.issue_number) { - issueNumber = parseInt(labelsItem.issue_number, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); return; - } - contextType = "issue/PR"; - } else { - core.setFailed('Target is "*" but no issue_number specified in labels item'); - return; - } - } else if (labelsTarget && labelsTarget !== "triggering") { - // Explicit issue number specified in target - issueNumber = parseInt(labelsTarget, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); - return; - } - contextType = "issue/PR"; - } else { - // Default behavior: use triggering issue/PR - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } else { - core.setFailed("Issue context detected but no issue found in payload"); + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); return; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - core.setFailed("Pull request context detected but no pull request found in payload"); + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } + else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } + else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (!isIssueContext && !isPRContext) { + core.setFailed("Not running in issue or pull request context, skipping label addition"); return; - } - } - } - - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); - return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` + } + let issueNumber; + let contextType; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } + else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } + else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } + else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } + else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw(` ## Label Addition No labels were added (no valid labels found in agent output). -` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` +`) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw(` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} -` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } +`) + .write(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); + diff --git a/pkg/workflow/js/collect_ndjson_output.js b/pkg/workflow/js/collect_ndjson_output.js index 7f090702b0a..65d65de6bbe 100644 --- a/pkg/workflow/js/collect_ndjson_output.js +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -1,739 +1,740 @@ async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") + continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); + diff --git a/pkg/workflow/js/create_discussion.js b/pkg/workflow/js/create_discussion.js index 67b2609eb35..50586387251 100644 --- a/pkg/workflow/js/create_discussion.js +++ b/pkg/workflow/js/create_discussion.js @@ -1,54 +1,55 @@ async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category_id) { - summaryContent += `**Category ID:** ${item.category_id}\n\n`; - } - summaryContent += "---\n\n"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - let discussionCategories = []; - let repositoryId = undefined; - try { - const repositoryQuery = ` + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); + if (createDiscussionItems.length === 0) { + core.warning("No create-discussion items found in agent output"); + return; + } + core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; + summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createDiscussionItems.length; i++) { + const item = createDiscussionItems[i]; + summaryContent += `### Discussion ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.category_id) { + summaryContent += `**Category ID:** ${item.category_id}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Discussion creation preview written to step summary"); + return; + } + let discussionCategories = []; + let repositoryId = undefined; + try { + const repositoryQuery = ` query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { id @@ -63,67 +64,65 @@ async function main() { } } `; - const queryResult = await github.graphql(repositoryQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - }); - if (!queryResult || !queryResult.repository) throw new Error("Failed to fetch repository information via GraphQL"); - repositoryId = queryResult.repository.id; - discussionCategories = queryResult.repository.discussionCategories.nodes || []; - core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if ( - errorMessage.includes("Not Found") || - errorMessage.includes("not found") || - errorMessage.includes("Could not resolve to a Repository") - ) { - core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); - core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); - return; + const queryResult = await github.graphql(repositoryQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + if (!queryResult || !queryResult.repository) + throw new Error("Failed to fetch repository information via GraphQL"); + repositoryId = queryResult.repository.id; + discussionCategories = queryResult.repository.discussionCategories.nodes || []; + core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); } - core.error(`Failed to get discussion categories: ${errorMessage}`); - throw error; - } - let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; - if (!categoryId && discussionCategories.length > 0) { - categoryId = discussionCategories[0].id; - core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); - } - if (!categoryId) { - core.error("No discussion category available and none specified in configuration"); - throw new Error("Discussion category is required but not available"); - } - if (!repositoryId) { - core.error("Repository ID is required for creating discussions"); - throw new Error("Repository ID is required but not available"); - } - const createdDiscussions = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - core.info( - `Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}` - ); - let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; - let bodyLines = createDiscussionItem.body.split("\n"); - if (!title) { - title = createDiscussionItem.body || "Agent Output"; + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Not Found") || + errorMessage.includes("not found") || + errorMessage.includes("Could not resolve to a Repository")) { + core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); + core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); + return; + } + core.error(`Failed to get discussion categories: ${errorMessage}`); + throw error; } - const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; + if (!categoryId && discussionCategories.length > 0) { + categoryId = discussionCategories[0].id; + core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` + if (!categoryId) { + core.error("No discussion category available and none specified in configuration"); + throw new Error("Discussion category is required but not available"); + } + if (!repositoryId) { + core.error("Repository ID is required for creating discussions"); + throw new Error("Repository ID is required but not available"); + } + const createdDiscussions = []; + for (let i = 0; i < createDiscussionItems.length; i++) { + const createDiscussionItem = createDiscussionItems[i]; + core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}`); + let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; + let bodyLines = createDiscussionItem.body.split("\n"); + if (!title) { + title = createDiscussionItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating discussion with title: ${title}`); + core.info(`Category ID: ${categoryId}`); + core.info(`Body length: ${body.length}`); + try { + const createDiscussionMutation = ` mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { createDiscussion(input: { repositoryId: $repositoryId, @@ -140,35 +139,37 @@ async function main() { } } `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error("Failed to create discussion: No discussion data returned"); - continue; - } - core.info("Created discussion #" + discussion.number + ": " + discussion.url); - createdDiscussions.push(discussion); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); - throw error; + const mutationResult = await github.graphql(createDiscussionMutation, { + repositoryId: repositoryId, + categoryId: categoryId, + title: title, + body: body, + }); + const discussion = mutationResult.createDiscussion.discussion; + if (!discussion) { + core.error("Failed to create discussion: No discussion data returned"); + continue; + } + core.info("Created discussion #" + discussion.number + ": " + discussion.url); + createdDiscussions.push(discussion); + if (i === createDiscussionItems.length - 1) { + core.setOutput("discussion_number", discussion.number); + core.setOutput("discussion_url", discussion.url); + } + } + catch (error) { + core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); + throw error; + } } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; + if (createdDiscussions.length > 0) { + let summaryContent = "\n\n## GitHub Discussions\n"; + for (const discussion of createdDiscussions) { + summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); } await main(); + diff --git a/pkg/workflow/js/create_issue.js b/pkg/workflow/js/create_issue.js index c49f85a56f6..ae17a98aadb 100644 --- a/pkg/workflow/js/create_issue.js +++ b/pkg/workflow/js/create_issue.js @@ -1,160 +1,159 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); + diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index 32fd65e5896..5700f7687a4 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -72,6 +72,7 @@ Please make changes and push them to the feature branch. // Verify conditional execution using BuildSafeOutputType combined with pull request context expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')", "github.event.issue.number", "github.event.issue.pull_request", @@ -296,6 +297,7 @@ This workflow has minimal push-to-pull-request-branch configuration. // Verify default conditional execution using BuildSafeOutputType combined with pull request context expectedConditionParts := []string{ + "always()", "contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')", "github.event.issue.number", "github.event.issue.pull_request", From bac0436064b7c089c7bbd33160b51cae9ab18596 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:32:59 +0000 Subject: [PATCH 16/18] Run format, lint, and recompile workflows Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/artifacts-summary.lock.yml | 2 +- .github/workflows/ci-doctor.lock.yml | 5 +- .github/workflows/dev.lock.yml | 4 +- .github/workflows/tidy.lock.yml | 5 +- pkg/cli/workflows/test-all.lock.yml | 18 +- .../test-claude-max-patch-size.lock.yml | 2 +- .../test-claude-patch-size-exceeded.lock.yml | 3 +- ...playwright-accessibility-contrast.lock.yml | 2 +- ...est-claude-playwright-screenshots.lock.yml | 4 +- .../test-copilot-max-patch-size.lock.yml | 2 +- .../test-copilot-patch-size-exceeded.lock.yml | 3 +- ...st-copilot-playwright-screenshots.lock.yml | 4 +- pkg/workflow/js/add_labels.js | 327 ++-- pkg/workflow/js/collect_ndjson_output.js | 1415 ++++++++--------- pkg/workflow/js/create_discussion.js | 271 ++-- pkg/workflow/js/create_issue.js | 289 ++-- 16 files changed, 1180 insertions(+), 1176 deletions(-) diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 60796294597..0bafca4d186 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -1876,7 +1876,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index c49f5b12d16..77b861ba8fa 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2343,7 +2343,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read @@ -2525,7 +2525,8 @@ jobs: add_comment: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'add-comment')) && ((github.event.issue.number) || (github.event.pull_request.number)) + ((always()) && (contains(needs.agent.outputs.output_types, 'add-comment'))) && ((github.event.issue.number) || + (github.event.pull_request.number)) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index eb5ab55d657..470b98df68e 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -2018,7 +2018,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read @@ -2199,7 +2199,7 @@ jobs: create_pull_request: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-pull-request') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-pull-request')) runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 5b5afebd7f4..2ef24191e31 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -2114,7 +2114,7 @@ jobs: create_pull_request: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-pull-request') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-pull-request')) runs-on: ubuntu-latest permissions: contents: write @@ -2445,7 +2445,8 @@ jobs: push_to_pull_request_branch: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + ((always()) && (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch'))) && + (((github.event.issue.number) && (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: diff --git a/pkg/cli/workflows/test-all.lock.yml b/pkg/cli/workflows/test-all.lock.yml index 8aab0b799b4..7a9ba8f7ce3 100644 --- a/pkg/cli/workflows/test-all.lock.yml +++ b/pkg/cli/workflows/test-all.lock.yml @@ -2887,7 +2887,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read @@ -3070,7 +3070,7 @@ jobs: add_comment: needs: agent - if: contains(needs.agent.outputs.output_types, 'add-comment') + if: (always()) && (contains(needs.agent.outputs.output_types, 'add-comment')) runs-on: ubuntu-latest permissions: contents: read @@ -3237,8 +3237,8 @@ jobs: create_pr_review_comment: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'create-pull-request-review-comment')) && (((github.event.issue.number) && - (github.event.issue.pull_request)) || (github.event.pull_request)) + ((always()) && (contains(needs.agent.outputs.output_types, 'create-pull-request-review-comment'))) && + (((github.event.issue.number) && (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: contents: read @@ -3431,7 +3431,7 @@ jobs: create_pull_request: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-pull-request') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-pull-request')) runs-on: ubuntu-latest permissions: contents: write @@ -3763,7 +3763,8 @@ jobs: add_labels: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'add-labels')) && ((github.event.issue.number) || (github.event.pull_request.number)) + ((always()) && (contains(needs.agent.outputs.output_types, 'add-labels'))) && ((github.event.issue.number) || + (github.event.pull_request.number)) runs-on: ubuntu-latest permissions: contents: read @@ -3960,7 +3961,7 @@ jobs: update_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'update-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'update-issue')) runs-on: ubuntu-latest permissions: contents: read @@ -4154,7 +4155,8 @@ jobs: push_to_pull_request_branch: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + ((always()) && (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch'))) && + (((github.event.issue.number) && (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: diff --git a/pkg/cli/workflows/test-claude-max-patch-size.lock.yml b/pkg/cli/workflows/test-claude-max-patch-size.lock.yml index 15edca9ceca..aabfa0e25f4 100644 --- a/pkg/cli/workflows/test-claude-max-patch-size.lock.yml +++ b/pkg/cli/workflows/test-claude-max-patch-size.lock.yml @@ -2382,7 +2382,7 @@ jobs: create_pull_request: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-pull-request') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-pull-request')) runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml index f59a868bf4d..03bdeef8511 100644 --- a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml @@ -2385,7 +2385,8 @@ jobs: push_to_pull_request_branch: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + ((always()) && (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch'))) && + (((github.event.issue.number) && (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: diff --git a/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml b/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml index dc74b4b246d..dd5d728881c 100644 --- a/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml +++ b/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml @@ -2294,7 +2294,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml b/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml index 2dd5f468776..5204202cfb3 100644 --- a/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml +++ b/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml @@ -2460,7 +2460,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read @@ -2642,7 +2642,7 @@ jobs: upload_assets: needs: agent - if: contains(needs.agent.outputs.output_types, 'publish-assets') + if: (always()) && (contains(needs.agent.outputs.output_types, 'upload-asset')) runs-on: ubuntu-latest permissions: contents: write # Required for creating orphaned branch and pushing assets diff --git a/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml b/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml index d65f1e982b2..fb7ce9c2a24 100644 --- a/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml +++ b/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml @@ -1938,7 +1938,7 @@ jobs: create_pull_request: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-pull-request') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-pull-request')) runs-on: ubuntu-latest permissions: contents: write diff --git a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml index 076144bf9df..c672d135c2a 100644 --- a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml @@ -1941,7 +1941,8 @@ jobs: push_to_pull_request_branch: needs: agent if: > - (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch')) && (((github.event.issue.number) && + ((always()) && (contains(needs.agent.outputs.output_types, 'push-to-pull-request-branch'))) && + (((github.event.issue.number) && (github.event.issue.pull_request)) || (github.event.pull_request)) runs-on: ubuntu-latest permissions: diff --git a/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml b/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml index c93b621ecf3..650e66f0530 100644 --- a/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml +++ b/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml @@ -1988,7 +1988,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read @@ -2170,7 +2170,7 @@ jobs: upload_assets: needs: agent - if: contains(needs.agent.outputs.output_types, 'publish-assets') + if: (always()) && (contains(needs.agent.outputs.output_types, 'upload-asset')) runs-on: ubuntu-latest permissions: contents: write # Required for creating orphaned branch and pushing assets diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index 17af6d0b7df..78cbb65870e 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -1,178 +1,177 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } - else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } - else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (!isIssueContext && !isPRContext) { - core.setFailed("Not running in issue or pull request context, skipping label addition"); - return; - } - let issueNumber; - let contextType; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } - else { - core.setFailed("Issue context detected but no issue found in payload"); - return; - } - } - else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } - else { - core.setFailed("Pull request context detected but no pull request found in payload"); - return; - } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); - return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } - else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw(` + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = + context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (!isIssueContext && !isPRContext) { + core.setFailed("Not running in issue or pull request context, skipping label addition"); + return; + } + let issueNumber; + let contextType; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw( + ` ## Label Addition No labels were added (no valid labels found in agent output). -`) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw(` +` + ) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw( + ` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} -`) - .write(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } +` + ) + .write(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); - diff --git a/pkg/workflow/js/collect_ndjson_output.js b/pkg/workflow/js/collect_ndjson_output.js index 65d65de6bbe..7f090702b0a 100644 --- a/pkg/workflow/js/collect_ndjson_output.js +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -1,740 +1,739 @@ async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + return isAllowed ? match : "(redacted)"; + }); } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") - continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } - } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, + return { + isValid: errors.length === 0, + errors, + normalizedItem, }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); + } + function parseJsonWithRepair(jsonStr) { try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - diff --git a/pkg/workflow/js/create_discussion.js b/pkg/workflow/js/create_discussion.js index 50586387251..67b2609eb35 100644 --- a/pkg/workflow/js/create_discussion.js +++ b/pkg/workflow/js/create_discussion.js @@ -1,55 +1,54 @@ async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category_id) { - summaryContent += `**Category ID:** ${item.category_id}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); + if (createDiscussionItems.length === 0) { + core.warning("No create-discussion items found in agent output"); + return; + } + core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; + summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createDiscussionItems.length; i++) { + const item = createDiscussionItems[i]; + summaryContent += `### Discussion ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.category_id) { + summaryContent += `**Category ID:** ${item.category_id}\n\n`; + } + summaryContent += "---\n\n"; } - let discussionCategories = []; - let repositoryId = undefined; - try { - const repositoryQuery = ` + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Discussion creation preview written to step summary"); + return; + } + let discussionCategories = []; + let repositoryId = undefined; + try { + const repositoryQuery = ` query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { id @@ -64,65 +63,67 @@ async function main() { } } `; - const queryResult = await github.graphql(repositoryQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - }); - if (!queryResult || !queryResult.repository) - throw new Error("Failed to fetch repository information via GraphQL"); - repositoryId = queryResult.repository.id; - discussionCategories = queryResult.repository.discussionCategories.nodes || []; - core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); + const queryResult = await github.graphql(repositoryQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + if (!queryResult || !queryResult.repository) throw new Error("Failed to fetch repository information via GraphQL"); + repositoryId = queryResult.repository.id; + discussionCategories = queryResult.repository.discussionCategories.nodes || []; + core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if ( + errorMessage.includes("Not Found") || + errorMessage.includes("not found") || + errorMessage.includes("Could not resolve to a Repository") + ) { + core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); + core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); + return; } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || - errorMessage.includes("not found") || - errorMessage.includes("Could not resolve to a Repository")) { - core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); - core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); - return; - } - core.error(`Failed to get discussion categories: ${errorMessage}`); - throw error; - } - let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; - if (!categoryId && discussionCategories.length > 0) { - categoryId = discussionCategories[0].id; - core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); + core.error(`Failed to get discussion categories: ${errorMessage}`); + throw error; + } + let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; + if (!categoryId && discussionCategories.length > 0) { + categoryId = discussionCategories[0].id; + core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); + } + if (!categoryId) { + core.error("No discussion category available and none specified in configuration"); + throw new Error("Discussion category is required but not available"); + } + if (!repositoryId) { + core.error("Repository ID is required for creating discussions"); + throw new Error("Repository ID is required but not available"); + } + const createdDiscussions = []; + for (let i = 0; i < createDiscussionItems.length; i++) { + const createDiscussionItem = createDiscussionItems[i]; + core.info( + `Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}` + ); + let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; + let bodyLines = createDiscussionItem.body.split("\n"); + if (!title) { + title = createDiscussionItem.body || "Agent Output"; } - if (!categoryId) { - core.error("No discussion category available and none specified in configuration"); - throw new Error("Discussion category is required but not available"); + const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - if (!repositoryId) { - core.error("Repository ID is required for creating discussions"); - throw new Error("Repository ID is required but not available"); - } - const createdDiscussions = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}`); - let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; - let bodyLines = createDiscussionItem.body.split("\n"); - if (!title) { - title = createDiscussionItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating discussion with title: ${title}`); + core.info(`Category ID: ${categoryId}`); + core.info(`Body length: ${body.length}`); + try { + const createDiscussionMutation = ` mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { createDiscussion(input: { repositoryId: $repositoryId, @@ -139,37 +140,35 @@ async function main() { } } `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error("Failed to create discussion: No discussion data returned"); - continue; - } - core.info("Created discussion #" + discussion.number + ": " + discussion.url); - createdDiscussions.push(discussion); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - } - catch (error) { - core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); - throw error; - } + const mutationResult = await github.graphql(createDiscussionMutation, { + repositoryId: repositoryId, + categoryId: categoryId, + title: title, + body: body, + }); + const discussion = mutationResult.createDiscussion.discussion; + if (!discussion) { + core.error("Failed to create discussion: No discussion data returned"); + continue; + } + core.info("Created discussion #" + discussion.number + ": " + discussion.url); + createdDiscussions.push(discussion); + if (i === createDiscussionItems.length - 1) { + core.setOutput("discussion_number", discussion.number); + core.setOutput("discussion_url", discussion.url); + } + } catch (error) { + core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); + throw error; } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdDiscussions.length > 0) { + let summaryContent = "\n\n## GitHub Discussions\n"; + for (const discussion of createdDiscussions) { + summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); } await main(); - diff --git a/pkg/workflow/js/create_issue.js b/pkg/workflow/js/create_issue.js index ae17a98aadb..c49f85a56f6 100644 --- a/pkg/workflow/js/create_issue.js +++ b/pkg/workflow/js/create_issue.js @@ -1,159 +1,160 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); + try { + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); - From 0551bcaeee6a3a9500cd49bbc390ed151c0df7a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:41:20 +0000 Subject: [PATCH 17/18] Fix missing_tool job to use BuildSafeOutputType with correct output type 'missing-tool' Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 1698 +++++++++++----------- pkg/workflow/js/add_labels.js | 326 ++--- pkg/workflow/js/collect_ndjson_output.js | 1414 +++++++++--------- pkg/workflow/js/create_discussion.js | 270 ++-- pkg/workflow/js/create_issue.js | 288 ++-- pkg/workflow/missing_tool.go | 5 +- 6 files changed, 2000 insertions(+), 2001 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 1456d647dc9..1296cfb3aae 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -978,742 +978,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -1987,169 +1987,167 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: needs: agent - if: (always()) && (contains(needs.agent.outputs.output_types, 'create-pull-request')) + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index 78cbb65870e..02e511a5293 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -1,177 +1,177 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } + else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } + else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (!isIssueContext && !isPRContext) { + core.setFailed("Not running in issue or pull request context, skipping label addition"); + return; + } + let issueNumber; + let contextType; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } + else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } + else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } + else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } + else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (!isIssueContext && !isPRContext) { - core.setFailed("Not running in issue or pull request context, skipping label addition"); - return; - } - let issueNumber; - let contextType; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } else { - core.setFailed("Issue context detected but no issue found in payload"); - return; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - core.setFailed("Pull request context detected but no pull request found in payload"); - return; - } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); - return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw(` ## Label Addition No labels were added (no valid labels found in agent output). -` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` +`) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw(` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} -` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } +`) + .write(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/pkg/workflow/js/collect_ndjson_output.js b/pkg/workflow/js/collect_ndjson_output.js index 7f090702b0a..19e898c5d13 100644 --- a/pkg/workflow/js/collect_ndjson_output.js +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -1,739 +1,739 @@ async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") + continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); diff --git a/pkg/workflow/js/create_discussion.js b/pkg/workflow/js/create_discussion.js index 67b2609eb35..75f5f0ef1dc 100644 --- a/pkg/workflow/js/create_discussion.js +++ b/pkg/workflow/js/create_discussion.js @@ -1,54 +1,55 @@ async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category_id) { - summaryContent += `**Category ID:** ${item.category_id}\n\n`; - } - summaryContent += "---\n\n"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - let discussionCategories = []; - let repositoryId = undefined; - try { - const repositoryQuery = ` + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); + if (createDiscussionItems.length === 0) { + core.warning("No create-discussion items found in agent output"); + return; + } + core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; + summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createDiscussionItems.length; i++) { + const item = createDiscussionItems[i]; + summaryContent += `### Discussion ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.category_id) { + summaryContent += `**Category ID:** ${item.category_id}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Discussion creation preview written to step summary"); + return; + } + let discussionCategories = []; + let repositoryId = undefined; + try { + const repositoryQuery = ` query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { id @@ -63,67 +64,65 @@ async function main() { } } `; - const queryResult = await github.graphql(repositoryQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - }); - if (!queryResult || !queryResult.repository) throw new Error("Failed to fetch repository information via GraphQL"); - repositoryId = queryResult.repository.id; - discussionCategories = queryResult.repository.discussionCategories.nodes || []; - core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if ( - errorMessage.includes("Not Found") || - errorMessage.includes("not found") || - errorMessage.includes("Could not resolve to a Repository") - ) { - core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); - core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); - return; + const queryResult = await github.graphql(repositoryQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + if (!queryResult || !queryResult.repository) + throw new Error("Failed to fetch repository information via GraphQL"); + repositoryId = queryResult.repository.id; + discussionCategories = queryResult.repository.discussionCategories.nodes || []; + core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); } - core.error(`Failed to get discussion categories: ${errorMessage}`); - throw error; - } - let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; - if (!categoryId && discussionCategories.length > 0) { - categoryId = discussionCategories[0].id; - core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); - } - if (!categoryId) { - core.error("No discussion category available and none specified in configuration"); - throw new Error("Discussion category is required but not available"); - } - if (!repositoryId) { - core.error("Repository ID is required for creating discussions"); - throw new Error("Repository ID is required but not available"); - } - const createdDiscussions = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - core.info( - `Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}` - ); - let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; - let bodyLines = createDiscussionItem.body.split("\n"); - if (!title) { - title = createDiscussionItem.body || "Agent Output"; + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Not Found") || + errorMessage.includes("not found") || + errorMessage.includes("Could not resolve to a Repository")) { + core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); + core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); + return; + } + core.error(`Failed to get discussion categories: ${errorMessage}`); + throw error; } - const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; + if (!categoryId && discussionCategories.length > 0) { + categoryId = discussionCategories[0].id; + core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` + if (!categoryId) { + core.error("No discussion category available and none specified in configuration"); + throw new Error("Discussion category is required but not available"); + } + if (!repositoryId) { + core.error("Repository ID is required for creating discussions"); + throw new Error("Repository ID is required but not available"); + } + const createdDiscussions = []; + for (let i = 0; i < createDiscussionItems.length; i++) { + const createDiscussionItem = createDiscussionItems[i]; + core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}`); + let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; + let bodyLines = createDiscussionItem.body.split("\n"); + if (!title) { + title = createDiscussionItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating discussion with title: ${title}`); + core.info(`Category ID: ${categoryId}`); + core.info(`Body length: ${body.length}`); + try { + const createDiscussionMutation = ` mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { createDiscussion(input: { repositoryId: $repositoryId, @@ -140,35 +139,36 @@ async function main() { } } `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error("Failed to create discussion: No discussion data returned"); - continue; - } - core.info("Created discussion #" + discussion.number + ": " + discussion.url); - createdDiscussions.push(discussion); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); - throw error; + const mutationResult = await github.graphql(createDiscussionMutation, { + repositoryId: repositoryId, + categoryId: categoryId, + title: title, + body: body, + }); + const discussion = mutationResult.createDiscussion.discussion; + if (!discussion) { + core.error("Failed to create discussion: No discussion data returned"); + continue; + } + core.info("Created discussion #" + discussion.number + ": " + discussion.url); + createdDiscussions.push(discussion); + if (i === createDiscussionItems.length - 1) { + core.setOutput("discussion_number", discussion.number); + core.setOutput("discussion_url", discussion.url); + } + } + catch (error) { + core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); + throw error; + } } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; + if (createdDiscussions.length > 0) { + let summaryContent = "\n\n## GitHub Discussions\n"; + for (const discussion of createdDiscussions) { + summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); } await main(); diff --git a/pkg/workflow/js/create_issue.js b/pkg/workflow/js/create_issue.js index c49f85a56f6..eaacd946262 100644 --- a/pkg/workflow/js/create_issue.js +++ b/pkg/workflow/js/create_issue.js @@ -1,160 +1,158 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); diff --git a/pkg/workflow/missing_tool.go b/pkg/workflow/missing_tool.go index 0c8c493ec56..f0863e7c1e7 100644 --- a/pkg/workflow/missing_tool.go +++ b/pkg/workflow/missing_tool.go @@ -52,11 +52,14 @@ func (c *Compiler) buildCreateOutputMissingToolJob(data *WorkflowData, mainJobNa "total_count": "${{ steps.missing_tool.outputs.total_count }}", } + // Build the job condition using BuildSafeOutputType + jobCondition := BuildSafeOutputType("missing-tool").Render() + // Create the job job := &Job{ Name: "missing_tool", RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), - If: "${{ always() }}", // Always run to capture missing tools + If: jobCondition, Permissions: "permissions:\n contents: read", // Only needs read access for logging TimeoutMinutes: 5, // Short timeout since it's just processing output Steps: steps, From 1fbb3fedc8c798b0e8bfcaae88816dd1b7138ee4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:20:02 +0000 Subject: [PATCH 18/18] Merge main, run format, lint, and recompile workflows Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/artifacts-summary.lock.yml | 2 +- .github/workflows/ci-doctor.lock.yml | 2 +- .github/workflows/dev.lock.yml | 1696 +++++++++-------- .github/workflows/tidy.lock.yml | 2 +- pkg/cli/workflows/test-all.lock.yml | 2 +- .../test-claude-max-patch-size.lock.yml | 2 +- .../test-claude-missing-tool.lock.yml | 2 +- .../test-claude-patch-size-exceeded.lock.yml | 2 +- ...playwright-accessibility-contrast.lock.yml | 2 +- ...est-claude-playwright-screenshots.lock.yml | 2 +- .../workflows/test-claude-safe-jobs.lock.yml | 2 +- .../test-copilot-max-patch-size.lock.yml | 2 +- .../test-copilot-missing-tool.lock.yml | 2 +- .../test-copilot-patch-size-exceeded.lock.yml | 2 +- ...st-copilot-playwright-screenshots.lock.yml | 2 +- .../workflows/test-copilot-safe-jobs.lock.yml | 2 +- .../workflows/test-playwright-args.lock.yml | 5 +- pkg/workflow/js/add_labels.js | 326 ++-- pkg/workflow/js/collect_ndjson_output.js | 1414 +++++++------- pkg/workflow/js/create_discussion.js | 270 +-- pkg/workflow/js/create_issue.js | 288 +-- 21 files changed, 2017 insertions(+), 2012 deletions(-) diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index f9e52b5ce66..1f2a68f1695 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -2134,7 +2134,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index c657b07f7d2..0472c348986 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2702,7 +2702,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 1296cfb3aae..83cbecbf189 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -978,742 +978,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + return isAllowed ? match : "(redacted)"; + }); } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") - continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); } + break; } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, + return { + isValid: true, + normalizedValue, }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -1987,162 +1987,164 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); + try { + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index ad5c89b4091..d783b41fc17 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -2817,7 +2817,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-all.lock.yml b/pkg/cli/workflows/test-all.lock.yml index 1be7b585f38..334571995b3 100644 --- a/pkg/cli/workflows/test-all.lock.yml +++ b/pkg/cli/workflows/test-all.lock.yml @@ -4464,7 +4464,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-claude-max-patch-size.lock.yml b/pkg/cli/workflows/test-claude-max-patch-size.lock.yml index bb9ef46c311..59123c82fad 100644 --- a/pkg/cli/workflows/test-claude-max-patch-size.lock.yml +++ b/pkg/cli/workflows/test-claude-max-patch-size.lock.yml @@ -2723,7 +2723,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-claude-missing-tool.lock.yml b/pkg/cli/workflows/test-claude-missing-tool.lock.yml index d546d37e686..9cb050a2bc5 100644 --- a/pkg/cli/workflows/test-claude-missing-tool.lock.yml +++ b/pkg/cli/workflows/test-claude-missing-tool.lock.yml @@ -2344,7 +2344,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml index ee572a5e1fd..804f71b0651 100644 --- a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml @@ -2697,7 +2697,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml b/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml index ebaf20b16c7..4c4052c9057 100644 --- a/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml +++ b/pkg/cli/workflows/test-claude-playwright-accessibility-contrast.lock.yml @@ -2488,7 +2488,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml b/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml index f1a9818b330..a05371631ea 100644 --- a/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml +++ b/pkg/cli/workflows/test-claude-playwright-screenshots.lock.yml @@ -2654,7 +2654,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-claude-safe-jobs.lock.yml b/pkg/cli/workflows/test-claude-safe-jobs.lock.yml index d5b6ddc9e56..07dff60fbb8 100644 --- a/pkg/cli/workflows/test-claude-safe-jobs.lock.yml +++ b/pkg/cli/workflows/test-claude-safe-jobs.lock.yml @@ -2492,7 +2492,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml b/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml index 97f2066990d..ad5eebd84b7 100644 --- a/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml +++ b/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml @@ -2343,7 +2343,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-copilot-missing-tool.lock.yml b/pkg/cli/workflows/test-copilot-missing-tool.lock.yml index 224f13bf307..e595b21b5e1 100644 --- a/pkg/cli/workflows/test-copilot-missing-tool.lock.yml +++ b/pkg/cli/workflows/test-copilot-missing-tool.lock.yml @@ -1963,7 +1963,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml index 2a4ad2a6dd0..1ad71db5b94 100644 --- a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml @@ -2317,7 +2317,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml b/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml index b54d9f55e5d..8b8df1c3bd9 100644 --- a/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml +++ b/pkg/cli/workflows/test-copilot-playwright-screenshots.lock.yml @@ -2246,7 +2246,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-copilot-safe-jobs.lock.yml b/pkg/cli/workflows/test-copilot-safe-jobs.lock.yml index 7ba72cc4b63..25e59ccfab2 100644 --- a/pkg/cli/workflows/test-copilot-safe-jobs.lock.yml +++ b/pkg/cli/workflows/test-copilot-safe-jobs.lock.yml @@ -2115,7 +2115,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read diff --git a/pkg/cli/workflows/test-playwright-args.lock.yml b/pkg/cli/workflows/test-playwright-args.lock.yml index bde7fbf7a8c..32113e8fbf2 100644 --- a/pkg/cli/workflows/test-playwright-args.lock.yml +++ b/pkg/cli/workflows/test-playwright-args.lock.yml @@ -2327,7 +2327,7 @@ jobs: create_issue: needs: agent - if: contains(needs.agent.outputs.output_types, 'create-issue') + if: (always()) && (contains(needs.agent.outputs.output_types, 'create-issue')) runs-on: ubuntu-latest permissions: contents: read @@ -2509,7 +2509,7 @@ jobs: missing_tool: needs: agent - if: ${{ always() }} + if: (always()) && (contains(needs.agent.outputs.output_types, 'missing-tool')) runs-on: ubuntu-latest permissions: contents: read @@ -2614,6 +2614,7 @@ jobs: upload_assets: needs: agent + if: (always()) && (contains(needs.agent.outputs.output_types, 'upload-asset')) runs-on: ubuntu-latest permissions: contents: write # Required for creating orphaned branch and pushing assets diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index 02e511a5293..78cbb65870e 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -1,177 +1,177 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } - else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } - else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (!isIssueContext && !isPRContext) { - core.setFailed("Not running in issue or pull request context, skipping label addition"); - return; - } - let issueNumber; - let contextType; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } - else { - core.setFailed("Issue context detected but no issue found in payload"); - return; - } - } - else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } - else { - core.setFailed("Pull request context detected but no pull request found in payload"); - return; - } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); - return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } - else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw(` + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = + context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (!isIssueContext && !isPRContext) { + core.setFailed("Not running in issue or pull request context, skipping label addition"); + return; + } + let issueNumber; + let contextType; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw( + ` ## Label Addition No labels were added (no valid labels found in agent output). -`) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw(` +` + ) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw( + ` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} -`) - .write(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } +` + ) + .write(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/pkg/workflow/js/collect_ndjson_output.js b/pkg/workflow/js/collect_ndjson_output.js index 19e898c5d13..7f090702b0a 100644 --- a/pkg/workflow/js/collect_ndjson_output.js +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -1,739 +1,739 @@ async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + return isAllowed ? match : "(redacted)"; + }); } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") - continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } - } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, + return { + isValid: errors.length === 0, + errors, + normalizedItem, }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); + } + function parseJsonWithRepair(jsonStr) { try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); diff --git a/pkg/workflow/js/create_discussion.js b/pkg/workflow/js/create_discussion.js index 75f5f0ef1dc..67b2609eb35 100644 --- a/pkg/workflow/js/create_discussion.js +++ b/pkg/workflow/js/create_discussion.js @@ -1,55 +1,54 @@ async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category_id) { - summaryContent += `**Category ID:** ${item.category_id}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); + if (createDiscussionItems.length === 0) { + core.warning("No create-discussion items found in agent output"); + return; + } + core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; + summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createDiscussionItems.length; i++) { + const item = createDiscussionItems[i]; + summaryContent += `### Discussion ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.category_id) { + summaryContent += `**Category ID:** ${item.category_id}\n\n`; + } + summaryContent += "---\n\n"; } - let discussionCategories = []; - let repositoryId = undefined; - try { - const repositoryQuery = ` + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Discussion creation preview written to step summary"); + return; + } + let discussionCategories = []; + let repositoryId = undefined; + try { + const repositoryQuery = ` query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { id @@ -64,65 +63,67 @@ async function main() { } } `; - const queryResult = await github.graphql(repositoryQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - }); - if (!queryResult || !queryResult.repository) - throw new Error("Failed to fetch repository information via GraphQL"); - repositoryId = queryResult.repository.id; - discussionCategories = queryResult.repository.discussionCategories.nodes || []; - core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); + const queryResult = await github.graphql(repositoryQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + if (!queryResult || !queryResult.repository) throw new Error("Failed to fetch repository information via GraphQL"); + repositoryId = queryResult.repository.id; + discussionCategories = queryResult.repository.discussionCategories.nodes || []; + core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if ( + errorMessage.includes("Not Found") || + errorMessage.includes("not found") || + errorMessage.includes("Could not resolve to a Repository") + ) { + core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); + core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); + return; } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || - errorMessage.includes("not found") || - errorMessage.includes("Could not resolve to a Repository")) { - core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); - core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); - return; - } - core.error(`Failed to get discussion categories: ${errorMessage}`); - throw error; - } - let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; - if (!categoryId && discussionCategories.length > 0) { - categoryId = discussionCategories[0].id; - core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); + core.error(`Failed to get discussion categories: ${errorMessage}`); + throw error; + } + let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; + if (!categoryId && discussionCategories.length > 0) { + categoryId = discussionCategories[0].id; + core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); + } + if (!categoryId) { + core.error("No discussion category available and none specified in configuration"); + throw new Error("Discussion category is required but not available"); + } + if (!repositoryId) { + core.error("Repository ID is required for creating discussions"); + throw new Error("Repository ID is required but not available"); + } + const createdDiscussions = []; + for (let i = 0; i < createDiscussionItems.length; i++) { + const createDiscussionItem = createDiscussionItems[i]; + core.info( + `Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}` + ); + let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; + let bodyLines = createDiscussionItem.body.split("\n"); + if (!title) { + title = createDiscussionItem.body || "Agent Output"; } - if (!categoryId) { - core.error("No discussion category available and none specified in configuration"); - throw new Error("Discussion category is required but not available"); + const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - if (!repositoryId) { - core.error("Repository ID is required for creating discussions"); - throw new Error("Repository ID is required but not available"); - } - const createdDiscussions = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}`); - let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; - let bodyLines = createDiscussionItem.body.split("\n"); - if (!title) { - title = createDiscussionItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating discussion with title: ${title}`); + core.info(`Category ID: ${categoryId}`); + core.info(`Body length: ${body.length}`); + try { + const createDiscussionMutation = ` mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { createDiscussion(input: { repositoryId: $repositoryId, @@ -139,36 +140,35 @@ async function main() { } } `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error("Failed to create discussion: No discussion data returned"); - continue; - } - core.info("Created discussion #" + discussion.number + ": " + discussion.url); - createdDiscussions.push(discussion); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - } - catch (error) { - core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); - throw error; - } + const mutationResult = await github.graphql(createDiscussionMutation, { + repositoryId: repositoryId, + categoryId: categoryId, + title: title, + body: body, + }); + const discussion = mutationResult.createDiscussion.discussion; + if (!discussion) { + core.error("Failed to create discussion: No discussion data returned"); + continue; + } + core.info("Created discussion #" + discussion.number + ": " + discussion.url); + createdDiscussions.push(discussion); + if (i === createDiscussionItems.length - 1) { + core.setOutput("discussion_number", discussion.number); + core.setOutput("discussion_url", discussion.url); + } + } catch (error) { + core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); + throw error; } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdDiscussions.length > 0) { + let summaryContent = "\n\n## GitHub Discussions\n"; + for (const discussion of createdDiscussions) { + summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); } await main(); diff --git a/pkg/workflow/js/create_issue.js b/pkg/workflow/js/create_issue.js index eaacd946262..c49f85a56f6 100644 --- a/pkg/workflow/js/create_issue.js +++ b/pkg/workflow/js/create_issue.js @@ -1,158 +1,160 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); + try { + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })();