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
9 changes: 8 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
"image": "mcr.microsoft.com/devcontainers/go:1-bookworm",
"customizations": {
"vscode": {
"extensions": ["golang.go", "GitHub.copilot-chat", "GitHub.copilot", "github.vscode-github-actions", "astro-build.astro-vscode", "DavidAnson.vscode-markdownlint"]
"extensions": [
"golang.go",
"GitHub.copilot-chat",
"GitHub.copilot",
"github.vscode-github-actions",
"astro-build.astro-vscode",
"DavidAnson.vscode-markdownlint"
]
},
"codespaces": {
"repositories": {
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/labs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn,
| [Sub-Issue Closer](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/sub-issue-closer.md) | copilot | [![Sub-Issue Closer](https://github.com/githubnext/gh-aw/actions/workflows/sub-issue-closer.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/sub-issue-closer.lock.yml) | - | - |
| [Super Linter Report](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/super-linter.md) | copilot | [![Super Linter Report](https://github.com/githubnext/gh-aw/actions/workflows/super-linter.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/super-linter.lock.yml) | `0 14 * * 1-5` | - |
| [Technical Doc Writer](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/technical-doc-writer.md) | copilot | [![Technical Doc Writer](https://github.com/githubnext/gh-aw/actions/workflows/technical-doc-writer.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/technical-doc-writer.lock.yml) | - | - |
| [Terminal Stylist](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/terminal-stylist.md) | copilot | [![Terminal Stylist](https://github.com/githubnext/gh-aw/actions/workflows/terminal-stylist.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/terminal-stylist.lock.yml) | - | - |
| [The Daily Repository Chronicle](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/daily-repo-chronicle.md) | copilot | [![The Daily Repository Chronicle](https://github.com/githubnext/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml) | `0 16 * * 1-5` | - |
| [The Great Escapi](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/firewall-escape.md) | copilot | [![The Great Escapi](https://github.com/githubnext/gh-aw/actions/workflows/firewall-escape.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/firewall-escape.lock.yml) | - | - |
| [Tidy](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/tidy.md) | copilot | [![Tidy](https://github.com/githubnext/gh-aw/actions/workflows/tidy.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/tidy.lock.yml) | `0 7 * * *` | - |
Expand Down
32 changes: 10 additions & 22 deletions pkg/workflow/update_discussion.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,16 @@ type UpdateDiscussionsConfig struct {

// parseUpdateDiscussionsConfig handles update-discussion configuration
func (c *Compiler) parseUpdateDiscussionsConfig(outputMap map[string]any) *UpdateDiscussionsConfig {
// Create config struct
cfg := &UpdateDiscussionsConfig{}

// Parse base config and entity-specific fields using generic helper
baseConfig, _ := c.parseUpdateEntityConfigWithFields(outputMap, UpdateEntityParseOptions{
EntityType: UpdateEntityDiscussion,
ConfigKey: "update-discussion",
Logger: updateDiscussionLog,
Fields: []UpdateEntityFieldSpec{
{Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},
{Name: "labels", Mode: FieldParsingKeyExistence, Dest: &cfg.Labels},
return parseUpdateEntityConfigTyped(c, outputMap,
UpdateEntityDiscussion, "update-discussion", updateDiscussionLog,
func(cfg *UpdateDiscussionsConfig) []UpdateEntityFieldSpec {
return []UpdateEntityFieldSpec{
{Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},
{Name: "labels", Mode: FieldParsingKeyExistence, Dest: &cfg.Labels},
}
},
CustomParser: func(cm map[string]any) {
func(cm map[string]any, cfg *UpdateDiscussionsConfig) {
// Parse allowed-labels using shared helper
cfg.AllowedLabels = parseAllowedLabelsFromConfig(cm)
if len(cfg.AllowedLabels) > 0 {
Expand All @@ -40,13 +36,5 @@ func (c *Compiler) parseUpdateDiscussionsConfig(outputMap map[string]any) *Updat
cfg.Labels = new(bool)
}
}
},
})
if baseConfig == nil {
return nil
}

// Set base fields
cfg.UpdateEntityConfig = *baseConfig
return cfg
})
}
93 changes: 93 additions & 0 deletions pkg/workflow/update_entity_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,96 @@ func (c *Compiler) parseUpdateEntityConfigWithFields(

return baseConfig, configMap
}

// parseUpdateEntityConfigTyped is a generic helper that eliminates the final
// scaffolding duplication in update entity parsers.
//
// It handles the complete parsing flow:
// 1. Creates entity-specific config struct
// 2. Builds field specs with pointers to config fields
// 3. Calls parseUpdateEntityConfigWithFields
// 4. Checks for nil result (early return)
// 5. Copies base config into entity-specific struct
// 6. Returns typed config
//
// Type parameter:
// - T: The entity-specific config type (must embed UpdateEntityConfig)
//
// Parameters:
// - c: Compiler instance
// - outputMap: The safe-outputs configuration map
// - entityType: Type of entity (issue, pull request, discussion, release)
// - configKey: Config key in YAML (e.g., "update-issue")
// - logger: Logger for this entity type
// - buildFields: Function that receives the config struct and returns field specs
// - customParser: Optional custom parser for special fields (can be nil)
//
// Returns:
// - *T: Pointer to the parsed and populated config struct, or nil if parsing failed
//
// Usage example:
//
// func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssuesConfig {
// return parseUpdateEntityConfigTyped(c, outputMap,
// UpdateEntityIssue, "update-issue", updateIssueLog,
// func(cfg *UpdateIssuesConfig) []UpdateEntityFieldSpec {
// return []UpdateEntityFieldSpec{
// {Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status},
// {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
// {Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},
// }
// }, nil)
// }
func parseUpdateEntityConfigTyped[T any](
c *Compiler,
outputMap map[string]any,
entityType UpdateEntityType,
configKey string,
logger *logger.Logger,
buildFields func(*T) []UpdateEntityFieldSpec,
customParser func(map[string]any, *T),
) *T {
// Create entity-specific config struct
cfg := new(T)

// Build field specs with pointers to config fields
fields := buildFields(cfg)

// Build parsing options
opts := UpdateEntityParseOptions{
EntityType: entityType,
ConfigKey: configKey,
Logger: logger,
Fields: fields,
}

// Add custom parser wrapper if provided
if customParser != nil {
opts.CustomParser = func(cm map[string]any) {
customParser(cm, cfg)
}
}

// Parse base config and entity-specific fields
baseConfig, _ := c.parseUpdateEntityConfigWithFields(outputMap, opts)
if baseConfig == nil {
return nil
}

// Use type assertion to set base config
// Since we can't use interface assertion with generics directly,
// we use type switch via any to assign the base config
cfgAny := any(cfg)
switch v := cfgAny.(type) {
case *UpdateIssuesConfig:
v.UpdateEntityConfig = *baseConfig
case *UpdateDiscussionsConfig:
v.UpdateEntityConfig = *baseConfig
case *UpdatePullRequestsConfig:
v.UpdateEntityConfig = *baseConfig
case *UpdateReleaseConfig:
v.UpdateEntityConfig = *baseConfig
}

return cfg
}
144 changes: 144 additions & 0 deletions pkg/workflow/update_entity_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,147 @@ func TestParseUpdateEntityConfigWithFields(t *testing.T) {
})
}
}

// TestParseUpdateEntityConfigTyped tests the generic wrapper function
func TestParseUpdateEntityConfigTyped(t *testing.T) {
tests := []struct {
name string
outputMap map[string]any
entityType UpdateEntityType
configKey string
wantNil bool
validateFunc func(*testing.T, *UpdateIssuesConfig) // Using UpdateIssuesConfig for simplicity
}{
{
name: "basic config with fields",
outputMap: map[string]any{
"update-issue": map[string]any{
"max": 2,
"title": nil,
"body": nil,
"status": nil,
},
},
entityType: UpdateEntityIssue,
configKey: "update-issue",
wantNil: false,
validateFunc: func(t *testing.T, cfg *UpdateIssuesConfig) {
if cfg.Max != 2 {
t.Errorf("Expected max=2, got %d", cfg.Max)
}
if cfg.Title == nil {
t.Error("Expected title to be non-nil")
}
if cfg.Body == nil {
t.Error("Expected body to be non-nil")
}
if cfg.Status == nil {
t.Error("Expected status to be non-nil")
}
},
},
{
name: "config with target",
outputMap: map[string]any{
"update-issue": map[string]any{
"target": "123",
"title": nil,
},
},
entityType: UpdateEntityIssue,
configKey: "update-issue",
wantNil: false,
validateFunc: func(t *testing.T, cfg *UpdateIssuesConfig) {
if cfg.Target != "123" {
t.Errorf("Expected target='123', got '%s'", cfg.Target)
}
if cfg.Title == nil {
t.Error("Expected title to be non-nil")
}
},
},
{
name: "missing config key returns nil",
outputMap: map[string]any{
"other-key": map[string]any{},
},
entityType: UpdateEntityIssue,
configKey: "update-issue",
wantNil: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiler := NewCompiler(false, "", "test")
result := parseUpdateEntityConfigTyped(compiler, tt.outputMap,
tt.entityType, tt.configKey, logger.New("test"),
func(cfg *UpdateIssuesConfig) []UpdateEntityFieldSpec {
return []UpdateEntityFieldSpec{
{Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status},
{Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},
}
}, nil)

if tt.wantNil {
if result != nil {
t.Errorf("Expected nil result, got %v", result)
}
} else {
if result == nil {
t.Errorf("Expected non-nil result, got nil")
} else if tt.validateFunc != nil {
tt.validateFunc(t, result)
}
}
})
}
}

// TestParseUpdateEntityConfigTypedWithCustomParser tests custom parser support
func TestParseUpdateEntityConfigTypedWithCustomParser(t *testing.T) {
outputMap := map[string]any{
"update-discussion": map[string]any{
"title": nil,
"labels": nil,
"allowed-labels": []any{"bug", "enhancement"},
},
}

compiler := NewCompiler(false, "", "test")
result := parseUpdateEntityConfigTyped(compiler, outputMap,
UpdateEntityDiscussion, "update-discussion", logger.New("test"),
func(cfg *UpdateDiscussionsConfig) []UpdateEntityFieldSpec {
return []UpdateEntityFieldSpec{
{Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
{Name: "labels", Mode: FieldParsingKeyExistence, Dest: &cfg.Labels},
}
},
func(cm map[string]any, cfg *UpdateDiscussionsConfig) {
cfg.AllowedLabels = parseAllowedLabelsFromConfig(cm)
})

if result == nil {
t.Fatal("Expected non-nil result")
}

if result.Title == nil {
t.Error("Expected title to be non-nil")
}

if result.Labels == nil {
t.Error("Expected labels to be non-nil")
}

expectedLabels := []string{"bug", "enhancement"}
if len(result.AllowedLabels) != len(expectedLabels) {
t.Fatalf("Expected %d allowed labels, got %d", len(expectedLabels), len(result.AllowedLabels))
}

for i, expected := range expectedLabels {
if result.AllowedLabels[i] != expected {
t.Errorf("Expected allowed label[%d]='%s', got '%s'", i, expected, result.AllowedLabels[i])
}
}
}
30 changes: 9 additions & 21 deletions pkg/workflow/update_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,13 @@ type UpdateIssuesConfig struct {

// parseUpdateIssuesConfig handles update-issue configuration
func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssuesConfig {
// Create config struct
cfg := &UpdateIssuesConfig{}

// Parse base config and entity-specific fields using generic helper
baseConfig, _ := c.parseUpdateEntityConfigWithFields(outputMap, UpdateEntityParseOptions{
EntityType: UpdateEntityIssue,
ConfigKey: "update-issue",
Logger: updateIssueLog,
Fields: []UpdateEntityFieldSpec{
{Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status},
{Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},
},
})
if baseConfig == nil {
return nil
}

// Set base fields
cfg.UpdateEntityConfig = *baseConfig
return cfg
return parseUpdateEntityConfigTyped(c, outputMap,
UpdateEntityIssue, "update-issue", updateIssueLog,
func(cfg *UpdateIssuesConfig) []UpdateEntityFieldSpec {
return []UpdateEntityFieldSpec{
{Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status},
{Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body},
}
}, nil)
}
28 changes: 8 additions & 20 deletions pkg/workflow/update_pull_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,12 @@ type UpdatePullRequestsConfig struct {
func (c *Compiler) parseUpdatePullRequestsConfig(outputMap map[string]any) *UpdatePullRequestsConfig {
updatePullRequestLog.Print("Parsing update pull request configuration")

// Create config struct
cfg := &UpdatePullRequestsConfig{}

// Parse base config and entity-specific fields using generic helper
baseConfig, _ := c.parseUpdateEntityConfigWithFields(outputMap, UpdateEntityParseOptions{
EntityType: UpdateEntityPullRequest,
ConfigKey: "update-pull-request",
Logger: updatePullRequestLog,
Fields: []UpdateEntityFieldSpec{
{Name: "title", Mode: FieldParsingBoolValue, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body},
},
})
if baseConfig == nil {
return nil
}

// Set base fields
cfg.UpdateEntityConfig = *baseConfig
return cfg
return parseUpdateEntityConfigTyped(c, outputMap,
UpdateEntityPullRequest, "update-pull-request", updatePullRequestLog,
func(cfg *UpdatePullRequestsConfig) []UpdateEntityFieldSpec {
return []UpdateEntityFieldSpec{
{Name: "title", Mode: FieldParsingBoolValue, Dest: &cfg.Title},
{Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body},
}
}, nil)
}
Loading