Skip to content
Open
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
8 changes: 8 additions & 0 deletions pkg/cli/logs_report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import (
"github.com/github/gh-aw/pkg/sliceutil"
)

func TestNormalizeJobNamePreservesPeriods(t *testing.T) {
t.Parallel()

if got := normalizeJobName(" Worker.V1 "); got != "worker.v1" {
t.Errorf("normalizeJobName() = %q, want %q; periods are not separators in log job-name matching", got, "worker.v1")
}
}

func TestToolUsageSummariesShareStatsBase(t *testing.T) {
t.Parallel()

Expand Down
45 changes: 30 additions & 15 deletions pkg/stringutil/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ The `stringutil` package is organized into focused sub-files:

| Sub-file | Functions |
|----------|-----------|
| `stringutil.go` | General string helpers |
| `stringutil.go` | General string helpers (`Truncate`, `FormatList`, `IsPositiveInteger`) |
| `whitespace.go` | Whitespace normalization |
| `version.go` | Version value coercion |
| `ansi.go` | ANSI escape-code stripping |
| `identifiers.go` | Workflow name and path normalization |
| `sanitize.go` | Security-sensitive string sanitization |
Expand Down Expand Up @@ -47,6 +49,20 @@ stringutil.Truncate("hello world", 8) // "hello..."
stringutil.Truncate("hi", 8) // "hi"
```

### `FormatList(items []string) string`

Formats a slice of strings as a natural-language list with an Oxford comma.

```go
stringutil.FormatList([]string{"a", "b", "c"}) // "a, b, and c"
```

### `IsPositiveInteger(s string) bool`

Returns `true` if and only if `s` is a decimal integer that is strictly greater than zero, has no leading zeros, and contains no non-digit characters. Returns `false` for `""`, `"0"`, negative strings (e.g. `"-5"`), strings with leading zeros (e.g. `"007"`), and non-numeric strings.

## Whitespace Normalization (`whitespace.go`)

### `NormalizeWhitespace(content string) string`

Normalizes trailing whitespace in multi-line content. Trims trailing spaces and tabs from every line, then ensures the content ends with exactly one newline (or is empty). This reduces spurious diffs caused by trailing-whitespace differences.
Expand All @@ -55,6 +71,8 @@ Normalizes trailing whitespace in multi-line content. Trims trailing spaces and

Removes shared leading indentation from non-empty lines in a multi-line string. This is useful for normalizing heredoc-like blocks while preserving relative indentation.

## Version Value Coercion (`version.go`)

### `ParseVersionValue(version any) string`

Converts a `any`-typed version value (typically from YAML parsing, which may produce `int`, `float64`, or `string`) into a string. Returns an empty string for nil.
Expand All @@ -65,18 +83,6 @@ stringutil.ParseVersionValue(20) // "20"
stringutil.ParseVersionValue(20.0) // "20"
```

### `FormatList(items []string) string`

Formats a slice of strings as a natural-language list with an Oxford comma.

```go
stringutil.FormatList([]string{"a", "b", "c"}) // "a, b, and c"
```

### `IsPositiveInteger(s string) bool`

Returns `true` if and only if `s` is a decimal integer that is strictly greater than zero, has no leading zeros, and contains no non-digit characters. Returns `false` for `""`, `"0"`, negative strings (e.g. `"-5"`), strings with leading zeros (e.g. `"007"`), and non-numeric strings.

## ANSI Escape Code Stripping (`ansi.go`)

### `StripANSI(s string) string`
Expand Down Expand Up @@ -109,6 +115,15 @@ stringutil.NormalizeSafeOutputIdentifier("create-issue") // "create_is
stringutil.NormalizeSafeOutputIdentifier("executor-workflow.agent") // "executor_workflow_agent"
```

### `NormalizeIdentifierToHyphens(identifier string) string`

Converts underscores **and periods** to hyphens, normalizing user-facing `underscore_separated` and dot-separated formats to the hyphen-separated format conventionally used for GitHub Actions job names. This is the hyphen-canonical counterpart to `NormalizeSafeOutputIdentifier`.

```go
stringutil.NormalizeIdentifierToHyphens("create_issue") // "create-issue"
stringutil.NormalizeIdentifierToHyphens("executor_workflow.agent") // "executor-workflow-agent"
```

### `MarkdownToLockFile(mdPath string) string`

Converts a workflow markdown path (`.md`) to its compiled lock file path (`.lock.yml`). Returns the path unchanged if it already ends with `.lock.yml`.
Expand Down Expand Up @@ -306,7 +321,7 @@ distance := stringutil.LevenshteinDistance("copiliot", "copilot")

## Design Decisions

- All debug output uses namespace-prefixed loggers (`stringutil:identifiers`, `stringutil:sanitize`, `stringutil:urls`, `stringutil:pat_validation`) and is only emitted when `DEBUG=stringutil:*`.
- All debug output uses namespace-prefixed loggers (`stringutil:identifiers`, `stringutil:sanitize`, `stringutil:urls`, `stringutil:pat_validation`, `stringutil:whitespace`, `stringutil:version`) and is only emitted when `DEBUG=stringutil:*`.
- `SanitizeErrorMessage` is intentionally conservative: it excludes common GitHub Actions keywords to avoid over-redacting legitimate error messages.
- `StripANSI` handles both CSI sequences (`ESC[`) and other ESC-prefixed sequences to cover the full range of ANSI escape codes found in terminal output.

Expand All @@ -324,7 +339,7 @@ This appendix is generated from the current non-test Go source files in this pac
| Types | 2 |
| Constants | 4 |
| Variables | 0 |
| Functions and methods | 28 |
| Functions and methods | 29 |
| Additional symbols documented in this appendix | 0 |

The sections above already mention every exported top-level symbol in the current source tree.
Expand Down
25 changes: 25 additions & 0 deletions pkg/stringutil/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,31 @@ func NormalizeSafeOutputIdentifier(identifier string) string {
return result
}

// NormalizeIdentifierToHyphens converts underscores and periods to hyphens.
// This is the hyphen-canonical counterpart to NormalizeSafeOutputIdentifier,
// standardizing identifiers to the hyphen-separated format conventionally used
// for GitHub Actions job names.
//
// Both underscore-separated and hyphen-separated formats are valid inputs.
// Periods are also replaced since job names should not contain them.
//
// This function performs normalization only - it assumes the input is already
// a valid identifier and does NOT perform character validation, case
// conversion, or whitespace trimming.
//
// Examples:
//
// NormalizeIdentifierToHyphens("create_issue") // returns "create-issue"
// NormalizeIdentifierToHyphens("create-issue") // returns "create-issue" (unchanged)
// NormalizeIdentifierToHyphens("add_comment") // returns "add-comment"
// NormalizeIdentifierToHyphens("update_pr") // returns "update-pr"
// NormalizeIdentifierToHyphens("executor_workflow.agent") // returns "executor-workflow-agent"
func NormalizeIdentifierToHyphens(identifier string) string {
result := strings.ReplaceAll(identifier, "_", "-")
result = strings.ReplaceAll(result, ".", "-")
return result
}

// MarkdownToLockFile converts a workflow markdown file path to its compiled lock file path.
// This is the standard transformation for agentic workflow files.
//
Expand Down
102 changes: 102 additions & 0 deletions pkg/stringutil/identifiers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,101 @@ func TestNormalizeSafeOutputIdentifier(t *testing.T) {
}
}

func TestNormalizeIdentifierToHyphens(t *testing.T) {
t.Parallel()
tests := []struct {
name string
identifier string
expected string
}{
{
name: "underscore-separated to hyphen",
identifier: "create_issue",
expected: "create-issue",
},
{
name: "already hyphen-separated",
identifier: "create-issue",
expected: "create-issue",
},
{
name: "multiple underscores",
identifier: "add_comment_to_issue",
expected: "add-comment-to-issue",
},
{
name: "mixed dashes and underscores",
identifier: "update-pr_status",
expected: "update-pr-status",
},
{
name: "no dashes or underscores",
identifier: "createissue",
expected: "createissue",
},
{
name: "single underscore",
identifier: "add_comment",
expected: "add-comment",
},
{
name: "trailing underscore",
identifier: "update_",
expected: "update-",
},
{
name: "leading underscore",
identifier: "_create",
expected: "-create",
},
{
name: "consecutive underscores",
identifier: "create__issue",
expected: "create--issue",
},
{
name: "empty string",
identifier: "",
expected: "",
},
{
name: "only underscores",
identifier: "___",
expected: "---",
},
{
name: "period in workflow name",
identifier: "executor_workflow.agent",
expected: "executor-workflow-agent",
},
{
name: "period only",
identifier: "my.workflow",
expected: "my-workflow",
},
{
name: "multiple periods",
identifier: "my.workflow.agent",
expected: "my-workflow-agent",
},
{
name: "period and underscores",
identifier: "my_workflow.agent",
expected: "my-workflow-agent",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := NormalizeIdentifierToHyphens(tt.identifier)
if result != tt.expected {
t.Errorf("NormalizeIdentifierToHyphens(%q) = %q, want %q", tt.identifier, result, tt.expected)
}
})
}
}

func BenchmarkNormalizeWorkflowName(b *testing.B) {
name := "weekly-research-workflow.lock.yml"
for b.Loop() {
Expand All @@ -190,6 +285,13 @@ func BenchmarkNormalizeSafeOutputIdentifier(b *testing.B) {
}
}

func BenchmarkNormalizeIdentifierToHyphens(b *testing.B) {
identifier := "create_pull_request_review_comment"
for b.Loop() {
NormalizeIdentifierToHyphens(identifier)
}
}

func TestMarkdownToLockFile(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
40 changes: 40 additions & 0 deletions pkg/stringutil/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,46 @@ func TestSpec_PublicAPI_NormalizeSafeOutputIdentifier(t *testing.T) {
}
}

// TestSpec_PublicAPI_NormalizeIdentifierToHyphens validates the documented
// behavior of NormalizeIdentifierToHyphens as described in the package README.md.
//
// Specification: "Converts underscores and periods to hyphens, normalizing
// user-facing underscore-separated and dot-separated formats to the
// hyphen-separated format conventionally used for GitHub Actions job names."
//
// Specification examples:
//
// stringutil.NormalizeIdentifierToHyphens("create_issue") // "create-issue"
// stringutil.NormalizeIdentifierToHyphens("executor_workflow.agent") // "executor-workflow-agent"
func TestSpec_PublicAPI_NormalizeIdentifierToHyphens(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{
name: "converts underscores to hyphens (documented example)",
input: "create_issue",
expected: "create-issue",
},
{
name: "converts underscores and periods to hyphens (documented example)",
input: "executor_workflow.agent",
expected: "executor-workflow-agent",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := NormalizeIdentifierToHyphens(tt.input)
assert.Equal(t, tt.expected, result,
"NormalizeIdentifierToHyphens(%q) should match documented output", tt.input)
})
}
}

// TestSpec_PublicAPI_MarkdownToLockFile validates the documented behavior of
// MarkdownToLockFile as described in the package README.md.
//
Expand Down
Loading
Loading