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
6 changes: 3 additions & 3 deletions .github/workflows/refiner.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData
orchestratorWorkflowLog.Print("Extracting YAML sections from frontmatter")

workflowData.On = c.extractTopLevelYAMLSection(frontmatter, "on")
workflowData.HasDispatchItemNumber = extractDispatchItemNumber(frontmatter)
workflowData.Permissions = c.extractPermissions(frontmatter)
workflowData.Network = c.extractTopLevelYAMLSection(frontmatter, "network")
workflowData.Concurrency = c.extractTopLevelYAMLSection(frontmatter, "concurrency")
Expand All @@ -237,6 +238,39 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData
workflowData.Cache = c.extractTopLevelYAMLSection(frontmatter, "cache")
}

// extractDispatchItemNumber reports whether the frontmatter's on.workflow_dispatch
// trigger exposes an item_number input. This is the signature produced by the label
// trigger shorthand (e.g. "on: pull_request labeled my-label"). Reading the
// structured map avoids re-parsing the rendered YAML string later.
func extractDispatchItemNumber(frontmatter map[string]any) bool {
onVal, ok := frontmatter["on"]
if !ok {
return false
}
onMap, ok := onVal.(map[string]any)
if !ok {
return false
}
wdVal, ok := onMap["workflow_dispatch"]
if !ok {
return false
}
wdMap, ok := wdVal.(map[string]any)
if !ok {
return false
}
inputsVal, ok := wdMap["inputs"]
if !ok {
return false
}
inputsMap, ok := inputsVal.(map[string]any)
if !ok {
return false
}
_, ok = inputsMap["item_number"]
return ok
}

// processAndMergeSteps handles the merging of imported steps with main workflow steps
func (c *Compiler) processAndMergeSteps(frontmatter map[string]any, workflowData *WorkflowData, importsResult *parser.ImportsResult) {
orchestratorWorkflowLog.Print("Processing and merging custom steps")
Expand Down
127 changes: 127 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1448,3 +1448,130 @@ func TestBuildInitialWorkflowData_FieldMapping(t *testing.T) {
assert.Equal(t, []string{"/imported1"}, workflowData.ImportedFiles)
assert.NotNil(t, workflowData.ImportInputs)
}

// TestExtractDispatchItemNumber tests that the item_number presence is detected
// directly from the frontmatter map rather than from re-parsing YAML strings.
func TestExtractDispatchItemNumber(t *testing.T) {
tests := []struct {
name string
frontmatter map[string]any
want bool
}{
{
name: "label trigger shorthand PR workflow has item_number",
frontmatter: map[string]any{
"on": map[string]any{
"pull_request": map[string]any{"types": []any{"labeled"}},
"workflow_dispatch": map[string]any{
"inputs": map[string]any{
"item_number": map[string]any{
"description": "The number of the pull request",
"required": true,
"type": "string",
},
},
},
},
},
want: true,
},
{
name: "label trigger shorthand issue workflow has item_number",
frontmatter: map[string]any{
"on": map[string]any{
"issues": map[string]any{"types": []any{"labeled"}},
"workflow_dispatch": map[string]any{
"inputs": map[string]any{
"item_number": map[string]any{
"description": "The number of the issue",
"required": true,
"type": "string",
},
},
},
},
},
want: true,
},
{
name: "plain workflow_dispatch without item_number",
frontmatter: map[string]any{
"on": map[string]any{
"workflow_dispatch": nil,
},
},
want: false,
},
{
name: "workflow_dispatch with unrelated inputs does not match",
frontmatter: map[string]any{
"on": map[string]any{
"workflow_dispatch": map[string]any{
"inputs": map[string]any{
"branch": map[string]any{"type": "string"},
},
},
},
},
want: false,
},
{
name: "no workflow_dispatch",
frontmatter: map[string]any{
"on": map[string]any{
"pull_request": map[string]any{"types": []any{"opened"}},
},
},
want: false,
},
{
name: "empty frontmatter",
frontmatter: map[string]any{},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractDispatchItemNumber(tt.frontmatter)
assert.Equal(t, tt.want, got, "extractDispatchItemNumber() mismatch")
})
}
}

// TestExtractYAMLSections_HasDispatchItemNumber verifies that extractYAMLSections
// populates WorkflowData.HasDispatchItemNumber from the frontmatter map.
func TestExtractYAMLSections_HasDispatchItemNumber(t *testing.T) {
compiler := NewCompiler()

t.Run("label trigger shorthand workflow sets HasDispatchItemNumber", func(t *testing.T) {
workflowData := &WorkflowData{}
frontmatter := map[string]any{
"on": map[string]any{
"pull_request": map[string]any{"types": []any{"labeled"}},
"workflow_dispatch": map[string]any{
"inputs": map[string]any{
"item_number": map[string]any{
"description": "The number of the pull request",
"required": true,
"type": "string",
},
},
},
},
}
compiler.extractYAMLSections(frontmatter, workflowData)
assert.True(t, workflowData.HasDispatchItemNumber, "should detect item_number from label trigger shorthand")
})

t.Run("plain workflow does not set HasDispatchItemNumber", func(t *testing.T) {
workflowData := &WorkflowData{}
frontmatter := map[string]any{
"on": map[string]any{
"pull_request": map[string]any{"types": []any{"opened"}},
},
}
compiler.extractYAMLSections(frontmatter, workflowData)
assert.False(t, workflowData.HasDispatchItemNumber, "should not set HasDispatchItemNumber for plain workflow")
})
}
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ type WorkflowData struct {
HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter
InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field)
CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter
HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand)
}

// BaseSafeOutputConfig holds common configuration fields for all safe output types
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/compiler_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,12 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, pre
userPromptChunks = append(userPromptChunks, runtimeImportMacro)
}

// Enhance entity number expressions with || inputs.item_number fallback when the
// workflow has a workflow_dispatch trigger with item_number (generated by the label
// trigger shorthand). This is applied after all expression mappings (including inline
// mode ones) have been collected so that every entity number reference gets the fallback.
applyWorkflowDispatchFallbacks(expressionMappings, data.HasDispatchItemNumber)

// Generate a single unified prompt creation step WITHOUT known needs expressions
// Known needs expressions are added later for the substitution step only
// This returns the combined expression mappings for use in the substitution step
Expand Down
57 changes: 51 additions & 6 deletions pkg/workflow/concurrency.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,33 +183,78 @@ func isSlashCommandWorkflow(on string) bool {
return strings.Contains(on, "slash_command")
}

// entityConcurrencyKey builds a ${{ ... }} concurrency-group expression for entity-number
// based workflows. primaryParts are the event-number identifiers (e.g.,
// "github.event.pull_request.number"), tailParts are the trailing fallbacks (e.g.,
// "github.ref", "github.run_id"). When hasItemNumber is true, "inputs.item_number" is
// inserted between the primary identifiers and the tail, providing a stable per-item
// key for manual workflow_dispatch runs triggered via the label trigger shorthand.
func entityConcurrencyKey(primaryParts []string, tailParts []string, hasItemNumber bool) string {
parts := make([]string, 0, len(primaryParts)+len(tailParts)+1)
parts = append(parts, primaryParts...)
if hasItemNumber {
parts = append(parts, "inputs.item_number")
}
parts = append(parts, tailParts...)
return "${{ " + strings.Join(parts, " || ") + " }}"
}

// buildConcurrencyGroupKeys builds an array of keys for the concurrency group
func buildConcurrencyGroupKeys(workflowData *WorkflowData, isCommandTrigger bool) []string {
keys := []string{"gh-aw", "${{ github.workflow }}"}

// Whether this workflow exposes inputs.item_number via workflow_dispatch (label trigger shorthand).
// When true, include it in the concurrency key so that manual dispatches for different items
// use distinct groups and don't cancel each other.
hasItemNumber := workflowData.HasDispatchItemNumber

if isCommandTrigger || isSlashCommandWorkflow(workflowData.On) {
// For command/slash_command workflows: use issue/PR number; fall back to run_id when
// neither is available (e.g. manual workflow_dispatch of the outer workflow).
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}")
} else if isPullRequestWorkflow(workflowData.On) && isIssueWorkflow(workflowData.On) {
// Mixed workflows with both issue and PR triggers
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}")
keys = append(keys, entityConcurrencyKey(
[]string{"github.event.issue.number", "github.event.pull_request.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isPullRequestWorkflow(workflowData.On) && isDiscussionWorkflow(workflowData.On) {
// Mixed workflows with PR and discussion triggers
keys = append(keys, "${{ github.event.pull_request.number || github.event.discussion.number || github.run_id }}")
keys = append(keys, entityConcurrencyKey(
[]string{"github.event.pull_request.number", "github.event.discussion.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isIssueWorkflow(workflowData.On) && isDiscussionWorkflow(workflowData.On) {
// Mixed workflows with issue and discussion triggers
keys = append(keys, "${{ github.event.issue.number || github.event.discussion.number || github.run_id }}")
keys = append(keys, entityConcurrencyKey(
[]string{"github.event.issue.number", "github.event.discussion.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isPullRequestWorkflow(workflowData.On) {
// PR workflows: use PR number, fall back to ref then run_id
keys = append(keys, "${{ github.event.pull_request.number || github.ref || github.run_id }}")
keys = append(keys, entityConcurrencyKey(
[]string{"github.event.pull_request.number"},
[]string{"github.ref", "github.run_id"},
hasItemNumber,
))
} else if isIssueWorkflow(workflowData.On) {
// Issue workflows: run_id is the fallback when no issue context is available
// (e.g. when a mixed-trigger workflow is started via workflow_dispatch).
keys = append(keys, "${{ github.event.issue.number || github.run_id }}")
keys = append(keys, entityConcurrencyKey(
[]string{"github.event.issue.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isDiscussionWorkflow(workflowData.On) {
// Discussion workflows: run_id is the fallback when no discussion context is available.
keys = append(keys, "${{ github.event.discussion.number || github.run_id }}")
keys = append(keys, entityConcurrencyKey(
[]string{"github.event.discussion.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isPushWorkflow(workflowData.On) {
// Push workflows: use ref to differentiate between branches
keys = append(keys, "${{ github.ref || github.run_id }}")
Expand Down
54 changes: 54 additions & 0 deletions pkg/workflow/concurrency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,60 @@ func TestBuildConcurrencyGroupKeys(t *testing.T) {
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.discussion.number || github.run_id }}"},
description: "Mixed discussion+workflow_dispatch workflows should fall back to run_id when discussion number is unavailable",
},
{
name: "Label trigger shorthand PR workflow should include inputs.item_number fallback",
workflowData: &WorkflowData{
On: `on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
item_number:
description: The number of the pull request
required: true
type: string`,
HasDispatchItemNumber: true,
},
isAliasTrigger: false,
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.pull_request.number || inputs.item_number || github.ref || github.run_id }}"},
description: "Label trigger shorthand PR workflows should include inputs.item_number before ref fallback",
},
{
name: "Label trigger shorthand issue workflow should include inputs.item_number fallback",
workflowData: &WorkflowData{
On: `on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
item_number:
description: The number of the issue
required: true
type: string`,
HasDispatchItemNumber: true,
},
isAliasTrigger: false,
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.issue.number || inputs.item_number || github.run_id }}"},
description: "Label trigger shorthand issue workflows should include inputs.item_number fallback",
},
{
name: "Label trigger shorthand discussion workflow should include inputs.item_number fallback",
workflowData: &WorkflowData{
On: `on:
discussion:
types: [labeled]
workflow_dispatch:
inputs:
item_number:
description: The number of the discussion
required: true
type: string`,
HasDispatchItemNumber: true,
},
isAliasTrigger: false,
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.discussion.number || inputs.item_number || github.run_id }}"},
description: "Label trigger shorthand discussion workflows should include inputs.item_number fallback",
},
}

for _, tt := range tests {
Expand Down
Loading