Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions pkg/stringutil/stringutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package stringutil
import (
"fmt"
"regexp"
"strconv"
"strings"
)

Expand Down Expand Up @@ -58,6 +59,30 @@ func ParseVersionValue(version any) string {
}
}

// IsPositiveInteger checks if a string is a positive integer.
// Returns true for strings like "1", "123", "999" but false for:
// - Zero ("0")
// - Negative numbers ("-5")
// - Numbers with leading zeros ("007")
// - Floating point numbers ("3.14")
// - Non-numeric strings ("abc")
// - Empty strings ("")
func IsPositiveInteger(s string) bool {
// Must not be empty
if s == "" {
return false
}

// Must not have leading zeros (except "0" itself, but that's not positive)
if len(s) > 1 && s[0] == '0' {
return false
}

// Must be numeric and > 0
num, err := strconv.ParseInt(s, 10, 64)
return err == nil && num > 0
}

// ansiEscapePattern matches ANSI escape sequences
// Pattern matches: ESC [ <optional params> <command letter>
// Examples: \x1b[0m, \x1b[31m, \x1b[1;32m
Expand Down
68 changes: 68 additions & 0 deletions pkg/stringutil/stringutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,3 +568,71 @@ func BenchmarkStripANSIEscapeCodes_WithCodes(b *testing.B) {
StripANSIEscapeCodes(s)
}
}

func TestIsPositiveInteger(t *testing.T) {
tests := []struct {
name string
s string
want bool
}{
{
name: "positive integer",
s: "123",
want: true,
},
{
name: "one",
s: "1",
want: true,
},
{
name: "large number",
s: "999999999",
want: true,
},
{
name: "zero",
s: "0",
want: false,
},
{
name: "negative",
s: "-5",
want: false,
},
{
name: "leading zeros",
s: "007",
want: false,
},
{
name: "float",
s: "3.14",
want: false,
},
{
name: "not a number",
s: "abc",
want: false,
},
{
name: "empty string",
s: "",
want: false,
},
{
name: "spaces",
s: " 123 ",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsPositiveInteger(tt.s)
if got != tt.want {
t.Errorf("IsPositiveInteger(%q) = %v, want %v", tt.s, got, tt.want)
}
})
}
}
15 changes: 15 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,21 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath
return errors.New(formattedErr)
}

// Validate safe-outputs target configuration
log.Printf("Validating safe-outputs target fields")
if err := validateSafeOutputsTarget(workflowData.SafeOutputs); err != nil {
formattedErr := console.FormatError(console.CompilerError{
Position: console.ErrorPosition{
File: markdownPath,
Line: 1,
Column: 1,
},
Type: "error",
Message: err.Error(),
})
return errors.New(formattedErr)
}

// Validate safe-outputs allowed-domains configuration
log.Printf("Validating safe-outputs allowed-domains")
if err := validateSafeOutputsAllowedDomains(workflowData.SafeOutputs); err != nil {
Expand Down
156 changes: 156 additions & 0 deletions pkg/workflow/safe_outputs_target_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package workflow

import (
"fmt"
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
"github.com/githubnext/gh-aw/pkg/stringutil"
)

var safeOutputsTargetValidationLog = logger.New("workflow:safe_outputs_target_validation")

// validateSafeOutputsTarget validates target fields in all safe-outputs configurations
// Valid target values:
// - "" (empty/default) - uses "triggering" behavior
// - "triggering" - targets the triggering issue/PR/discussion
// - "*" - targets any item specified in the output
// - A positive integer as a string (e.g., "123")
// - A GitHub Actions expression (e.g., "${{ github.event.issue.number }}")
func validateSafeOutputsTarget(config *SafeOutputsConfig) error {
if config == nil {
return nil
}

safeOutputsTargetValidationLog.Print("Validating safe-outputs target fields")

// List of configs to validate - each with a name for error messages
type targetConfig struct {
name string
target string
}

var configs []targetConfig

// Collect all target fields from various safe-output configurations
if config.UpdateIssues != nil {
configs = append(configs, targetConfig{"update-issue", config.UpdateIssues.Target})
}
if config.UpdateDiscussions != nil {
configs = append(configs, targetConfig{"update-discussion", config.UpdateDiscussions.Target})
}
if config.UpdatePullRequests != nil {
configs = append(configs, targetConfig{"update-pull-request", config.UpdatePullRequests.Target})
}
if config.CloseIssues != nil {
configs = append(configs, targetConfig{"close-issue", config.CloseIssues.Target})
}
if config.CloseDiscussions != nil {
configs = append(configs, targetConfig{"close-discussion", config.CloseDiscussions.Target})
}
if config.ClosePullRequests != nil {
configs = append(configs, targetConfig{"close-pull-request", config.ClosePullRequests.Target})
}
if config.AddLabels != nil {
configs = append(configs, targetConfig{"add-labels", config.AddLabels.Target})
}
if config.RemoveLabels != nil {
configs = append(configs, targetConfig{"remove-labels", config.RemoveLabels.Target})
}
if config.AddReviewer != nil {
configs = append(configs, targetConfig{"add-reviewer", config.AddReviewer.Target})
}
if config.AssignMilestone != nil {
configs = append(configs, targetConfig{"assign-milestone", config.AssignMilestone.Target})
}
if config.AssignToAgent != nil {
configs = append(configs, targetConfig{"assign-to-agent", config.AssignToAgent.Target})
}
if config.AssignToUser != nil {
configs = append(configs, targetConfig{"assign-to-user", config.AssignToUser.Target})
}
if config.LinkSubIssue != nil {
configs = append(configs, targetConfig{"link-sub-issue", config.LinkSubIssue.Target})
}
if config.HideComment != nil {
configs = append(configs, targetConfig{"hide-comment", config.HideComment.Target})
}
if config.MarkPullRequestAsReadyForReview != nil {
configs = append(configs, targetConfig{"mark-pull-request-as-ready-for-review", config.MarkPullRequestAsReadyForReview.Target})
}
if config.AddComments != nil {
configs = append(configs, targetConfig{"add-comment", config.AddComments.Target})
}
if config.CreatePullRequestReviewComments != nil {
configs = append(configs, targetConfig{"create-pull-request-review-comment", config.CreatePullRequestReviewComments.Target})
}
if config.PushToPullRequestBranch != nil {
configs = append(configs, targetConfig{"push-to-pull-request-branch", config.PushToPullRequestBranch.Target})
}

// Validate each target field
for _, cfg := range configs {
if err := validateTargetValue(cfg.name, cfg.target); err != nil {
return err
}
}

safeOutputsTargetValidationLog.Printf("Validated %d target fields", len(configs))
return nil
}

// validateTargetValue validates a single target value
func validateTargetValue(configName, target string) error {
// Empty or "triggering" are always valid
if target == "" || target == "triggering" {
return nil
}

// "*" is valid (any item)
if target == "*" {
return nil
}

// Check if it's a GitHub Actions expression
if isGitHubExpression(target) {
safeOutputsTargetValidationLog.Printf("Target for %s is a GitHub Actions expression", configName)
return nil
}

// Check if it's a positive integer
if stringutil.IsPositiveInteger(target) {
safeOutputsTargetValidationLog.Printf("Target for %s is a valid number: %s", configName, target)
return nil
}

// Build a helpful suggestion based on the invalid value
suggestion := ""
if target == "event" || strings.Contains(target, "github.event") {
suggestion = "\n\nDid you mean to use \"${{ github.event.issue.number }}\" instead of \"" + target + "\"?"
}

// Invalid target value
return fmt.Errorf(
"invalid target value for %s: %q\n\nValid target values are:\n - \"triggering\" (default) - targets the triggering issue/PR/discussion\n - \"*\" - targets any item specified in the output\n - A positive integer (e.g., \"123\")\n - A GitHub Actions expression (e.g., \"${{ github.event.issue.number }}\")%s",
configName,
target,
suggestion,
)
}

// isGitHubExpression checks if a string is a valid GitHub Actions expression
// A valid expression must have properly balanced ${{ and }} markers
func isGitHubExpression(s string) bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot use expression parser to validate correct expression, there should be a validator already

// Must contain both opening and closing markers
if !strings.Contains(s, "${{") || !strings.Contains(s, "}}") {
return false
}

// Basic validation: opening marker must come before closing marker
openIndex := strings.Index(s, "${{")
closeIndex := strings.Index(s, "}}")

// The closing marker must come after the opening marker
// and there must be something between them
return openIndex >= 0 && closeIndex > openIndex+3
}
Loading
Loading