Skip to content

Commit ce49917

Browse files
authored
Fix: wire inputs.item_number fallback for label trigger shorthand workflows (#19795)
1 parent 8c8e3bc commit ce49917

10 files changed

+585
-9
lines changed

.github/workflows/refiner.lock.yml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/workflow/compiler_orchestrator_workflow.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData
220220
orchestratorWorkflowLog.Print("Extracting YAML sections from frontmatter")
221221

222222
workflowData.On = c.extractTopLevelYAMLSection(frontmatter, "on")
223+
workflowData.HasDispatchItemNumber = extractDispatchItemNumber(frontmatter)
223224
workflowData.Permissions = c.extractPermissions(frontmatter)
224225
workflowData.Network = c.extractTopLevelYAMLSection(frontmatter, "network")
225226
workflowData.Concurrency = c.extractTopLevelYAMLSection(frontmatter, "concurrency")
@@ -237,6 +238,39 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData
237238
workflowData.Cache = c.extractTopLevelYAMLSection(frontmatter, "cache")
238239
}
239240

241+
// extractDispatchItemNumber reports whether the frontmatter's on.workflow_dispatch
242+
// trigger exposes an item_number input. This is the signature produced by the label
243+
// trigger shorthand (e.g. "on: pull_request labeled my-label"). Reading the
244+
// structured map avoids re-parsing the rendered YAML string later.
245+
func extractDispatchItemNumber(frontmatter map[string]any) bool {
246+
onVal, ok := frontmatter["on"]
247+
if !ok {
248+
return false
249+
}
250+
onMap, ok := onVal.(map[string]any)
251+
if !ok {
252+
return false
253+
}
254+
wdVal, ok := onMap["workflow_dispatch"]
255+
if !ok {
256+
return false
257+
}
258+
wdMap, ok := wdVal.(map[string]any)
259+
if !ok {
260+
return false
261+
}
262+
inputsVal, ok := wdMap["inputs"]
263+
if !ok {
264+
return false
265+
}
266+
inputsMap, ok := inputsVal.(map[string]any)
267+
if !ok {
268+
return false
269+
}
270+
_, ok = inputsMap["item_number"]
271+
return ok
272+
}
273+
240274
// processAndMergeSteps handles the merging of imported steps with main workflow steps
241275
func (c *Compiler) processAndMergeSteps(frontmatter map[string]any, workflowData *WorkflowData, importsResult *parser.ImportsResult) {
242276
orchestratorWorkflowLog.Print("Processing and merging custom steps")

pkg/workflow/compiler_orchestrator_workflow_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,3 +1448,130 @@ func TestBuildInitialWorkflowData_FieldMapping(t *testing.T) {
14481448
assert.Equal(t, []string{"/imported1"}, workflowData.ImportedFiles)
14491449
assert.NotNil(t, workflowData.ImportInputs)
14501450
}
1451+
1452+
// TestExtractDispatchItemNumber tests that the item_number presence is detected
1453+
// directly from the frontmatter map rather than from re-parsing YAML strings.
1454+
func TestExtractDispatchItemNumber(t *testing.T) {
1455+
tests := []struct {
1456+
name string
1457+
frontmatter map[string]any
1458+
want bool
1459+
}{
1460+
{
1461+
name: "label trigger shorthand PR workflow has item_number",
1462+
frontmatter: map[string]any{
1463+
"on": map[string]any{
1464+
"pull_request": map[string]any{"types": []any{"labeled"}},
1465+
"workflow_dispatch": map[string]any{
1466+
"inputs": map[string]any{
1467+
"item_number": map[string]any{
1468+
"description": "The number of the pull request",
1469+
"required": true,
1470+
"type": "string",
1471+
},
1472+
},
1473+
},
1474+
},
1475+
},
1476+
want: true,
1477+
},
1478+
{
1479+
name: "label trigger shorthand issue workflow has item_number",
1480+
frontmatter: map[string]any{
1481+
"on": map[string]any{
1482+
"issues": map[string]any{"types": []any{"labeled"}},
1483+
"workflow_dispatch": map[string]any{
1484+
"inputs": map[string]any{
1485+
"item_number": map[string]any{
1486+
"description": "The number of the issue",
1487+
"required": true,
1488+
"type": "string",
1489+
},
1490+
},
1491+
},
1492+
},
1493+
},
1494+
want: true,
1495+
},
1496+
{
1497+
name: "plain workflow_dispatch without item_number",
1498+
frontmatter: map[string]any{
1499+
"on": map[string]any{
1500+
"workflow_dispatch": nil,
1501+
},
1502+
},
1503+
want: false,
1504+
},
1505+
{
1506+
name: "workflow_dispatch with unrelated inputs does not match",
1507+
frontmatter: map[string]any{
1508+
"on": map[string]any{
1509+
"workflow_dispatch": map[string]any{
1510+
"inputs": map[string]any{
1511+
"branch": map[string]any{"type": "string"},
1512+
},
1513+
},
1514+
},
1515+
},
1516+
want: false,
1517+
},
1518+
{
1519+
name: "no workflow_dispatch",
1520+
frontmatter: map[string]any{
1521+
"on": map[string]any{
1522+
"pull_request": map[string]any{"types": []any{"opened"}},
1523+
},
1524+
},
1525+
want: false,
1526+
},
1527+
{
1528+
name: "empty frontmatter",
1529+
frontmatter: map[string]any{},
1530+
want: false,
1531+
},
1532+
}
1533+
1534+
for _, tt := range tests {
1535+
t.Run(tt.name, func(t *testing.T) {
1536+
got := extractDispatchItemNumber(tt.frontmatter)
1537+
assert.Equal(t, tt.want, got, "extractDispatchItemNumber() mismatch")
1538+
})
1539+
}
1540+
}
1541+
1542+
// TestExtractYAMLSections_HasDispatchItemNumber verifies that extractYAMLSections
1543+
// populates WorkflowData.HasDispatchItemNumber from the frontmatter map.
1544+
func TestExtractYAMLSections_HasDispatchItemNumber(t *testing.T) {
1545+
compiler := NewCompiler()
1546+
1547+
t.Run("label trigger shorthand workflow sets HasDispatchItemNumber", func(t *testing.T) {
1548+
workflowData := &WorkflowData{}
1549+
frontmatter := map[string]any{
1550+
"on": map[string]any{
1551+
"pull_request": map[string]any{"types": []any{"labeled"}},
1552+
"workflow_dispatch": map[string]any{
1553+
"inputs": map[string]any{
1554+
"item_number": map[string]any{
1555+
"description": "The number of the pull request",
1556+
"required": true,
1557+
"type": "string",
1558+
},
1559+
},
1560+
},
1561+
},
1562+
}
1563+
compiler.extractYAMLSections(frontmatter, workflowData)
1564+
assert.True(t, workflowData.HasDispatchItemNumber, "should detect item_number from label trigger shorthand")
1565+
})
1566+
1567+
t.Run("plain workflow does not set HasDispatchItemNumber", func(t *testing.T) {
1568+
workflowData := &WorkflowData{}
1569+
frontmatter := map[string]any{
1570+
"on": map[string]any{
1571+
"pull_request": map[string]any{"types": []any{"opened"}},
1572+
},
1573+
}
1574+
compiler.extractYAMLSections(frontmatter, workflowData)
1575+
assert.False(t, workflowData.HasDispatchItemNumber, "should not set HasDispatchItemNumber for plain workflow")
1576+
})
1577+
}

pkg/workflow/compiler_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ type WorkflowData struct {
430430
HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter
431431
InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field)
432432
CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter
433+
HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand)
433434
}
434435

435436
// BaseSafeOutputConfig holds common configuration fields for all safe output types

pkg/workflow/compiler_yaml.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,12 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, pre
422422
userPromptChunks = append(userPromptChunks, runtimeImportMacro)
423423
}
424424

425+
// Enhance entity number expressions with || inputs.item_number fallback when the
426+
// workflow has a workflow_dispatch trigger with item_number (generated by the label
427+
// trigger shorthand). This is applied after all expression mappings (including inline
428+
// mode ones) have been collected so that every entity number reference gets the fallback.
429+
applyWorkflowDispatchFallbacks(expressionMappings, data.HasDispatchItemNumber)
430+
425431
// Generate a single unified prompt creation step WITHOUT known needs expressions
426432
// Known needs expressions are added later for the substitution step only
427433
// This returns the combined expression mappings for use in the substitution step

pkg/workflow/concurrency.go

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,33 +183,78 @@ func isSlashCommandWorkflow(on string) bool {
183183
return strings.Contains(on, "slash_command")
184184
}
185185

186+
// entityConcurrencyKey builds a ${{ ... }} concurrency-group expression for entity-number
187+
// based workflows. primaryParts are the event-number identifiers (e.g.,
188+
// "github.event.pull_request.number"), tailParts are the trailing fallbacks (e.g.,
189+
// "github.ref", "github.run_id"). When hasItemNumber is true, "inputs.item_number" is
190+
// inserted between the primary identifiers and the tail, providing a stable per-item
191+
// key for manual workflow_dispatch runs triggered via the label trigger shorthand.
192+
func entityConcurrencyKey(primaryParts []string, tailParts []string, hasItemNumber bool) string {
193+
parts := make([]string, 0, len(primaryParts)+len(tailParts)+1)
194+
parts = append(parts, primaryParts...)
195+
if hasItemNumber {
196+
parts = append(parts, "inputs.item_number")
197+
}
198+
parts = append(parts, tailParts...)
199+
return "${{ " + strings.Join(parts, " || ") + " }}"
200+
}
201+
186202
// buildConcurrencyGroupKeys builds an array of keys for the concurrency group
187203
func buildConcurrencyGroupKeys(workflowData *WorkflowData, isCommandTrigger bool) []string {
188204
keys := []string{"gh-aw", "${{ github.workflow }}"}
189205

206+
// Whether this workflow exposes inputs.item_number via workflow_dispatch (label trigger shorthand).
207+
// When true, include it in the concurrency key so that manual dispatches for different items
208+
// use distinct groups and don't cancel each other.
209+
hasItemNumber := workflowData.HasDispatchItemNumber
210+
190211
if isCommandTrigger || isSlashCommandWorkflow(workflowData.On) {
191212
// For command/slash_command workflows: use issue/PR number; fall back to run_id when
192213
// neither is available (e.g. manual workflow_dispatch of the outer workflow).
193214
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}")
194215
} else if isPullRequestWorkflow(workflowData.On) && isIssueWorkflow(workflowData.On) {
195216
// Mixed workflows with both issue and PR triggers
196-
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}")
217+
keys = append(keys, entityConcurrencyKey(
218+
[]string{"github.event.issue.number", "github.event.pull_request.number"},
219+
[]string{"github.run_id"},
220+
hasItemNumber,
221+
))
197222
} else if isPullRequestWorkflow(workflowData.On) && isDiscussionWorkflow(workflowData.On) {
198223
// Mixed workflows with PR and discussion triggers
199-
keys = append(keys, "${{ github.event.pull_request.number || github.event.discussion.number || github.run_id }}")
224+
keys = append(keys, entityConcurrencyKey(
225+
[]string{"github.event.pull_request.number", "github.event.discussion.number"},
226+
[]string{"github.run_id"},
227+
hasItemNumber,
228+
))
200229
} else if isIssueWorkflow(workflowData.On) && isDiscussionWorkflow(workflowData.On) {
201230
// Mixed workflows with issue and discussion triggers
202-
keys = append(keys, "${{ github.event.issue.number || github.event.discussion.number || github.run_id }}")
231+
keys = append(keys, entityConcurrencyKey(
232+
[]string{"github.event.issue.number", "github.event.discussion.number"},
233+
[]string{"github.run_id"},
234+
hasItemNumber,
235+
))
203236
} else if isPullRequestWorkflow(workflowData.On) {
204237
// PR workflows: use PR number, fall back to ref then run_id
205-
keys = append(keys, "${{ github.event.pull_request.number || github.ref || github.run_id }}")
238+
keys = append(keys, entityConcurrencyKey(
239+
[]string{"github.event.pull_request.number"},
240+
[]string{"github.ref", "github.run_id"},
241+
hasItemNumber,
242+
))
206243
} else if isIssueWorkflow(workflowData.On) {
207244
// Issue workflows: run_id is the fallback when no issue context is available
208245
// (e.g. when a mixed-trigger workflow is started via workflow_dispatch).
209-
keys = append(keys, "${{ github.event.issue.number || github.run_id }}")
246+
keys = append(keys, entityConcurrencyKey(
247+
[]string{"github.event.issue.number"},
248+
[]string{"github.run_id"},
249+
hasItemNumber,
250+
))
210251
} else if isDiscussionWorkflow(workflowData.On) {
211252
// Discussion workflows: run_id is the fallback when no discussion context is available.
212-
keys = append(keys, "${{ github.event.discussion.number || github.run_id }}")
253+
keys = append(keys, entityConcurrencyKey(
254+
[]string{"github.event.discussion.number"},
255+
[]string{"github.run_id"},
256+
hasItemNumber,
257+
))
213258
} else if isPushWorkflow(workflowData.On) {
214259
// Push workflows: use ref to differentiate between branches
215260
keys = append(keys, "${{ github.ref || github.run_id }}")

pkg/workflow/concurrency_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,60 @@ func TestBuildConcurrencyGroupKeys(t *testing.T) {
850850
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.discussion.number || github.run_id }}"},
851851
description: "Mixed discussion+workflow_dispatch workflows should fall back to run_id when discussion number is unavailable",
852852
},
853+
{
854+
name: "Label trigger shorthand PR workflow should include inputs.item_number fallback",
855+
workflowData: &WorkflowData{
856+
On: `on:
857+
pull_request:
858+
types: [labeled]
859+
workflow_dispatch:
860+
inputs:
861+
item_number:
862+
description: The number of the pull request
863+
required: true
864+
type: string`,
865+
HasDispatchItemNumber: true,
866+
},
867+
isAliasTrigger: false,
868+
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.pull_request.number || inputs.item_number || github.ref || github.run_id }}"},
869+
description: "Label trigger shorthand PR workflows should include inputs.item_number before ref fallback",
870+
},
871+
{
872+
name: "Label trigger shorthand issue workflow should include inputs.item_number fallback",
873+
workflowData: &WorkflowData{
874+
On: `on:
875+
issues:
876+
types: [labeled]
877+
workflow_dispatch:
878+
inputs:
879+
item_number:
880+
description: The number of the issue
881+
required: true
882+
type: string`,
883+
HasDispatchItemNumber: true,
884+
},
885+
isAliasTrigger: false,
886+
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.issue.number || inputs.item_number || github.run_id }}"},
887+
description: "Label trigger shorthand issue workflows should include inputs.item_number fallback",
888+
},
889+
{
890+
name: "Label trigger shorthand discussion workflow should include inputs.item_number fallback",
891+
workflowData: &WorkflowData{
892+
On: `on:
893+
discussion:
894+
types: [labeled]
895+
workflow_dispatch:
896+
inputs:
897+
item_number:
898+
description: The number of the discussion
899+
required: true
900+
type: string`,
901+
HasDispatchItemNumber: true,
902+
},
903+
isAliasTrigger: false,
904+
expected: []string{"gh-aw", "${{ github.workflow }}", "${{ github.event.discussion.number || inputs.item_number || github.run_id }}"},
905+
description: "Label trigger shorthand discussion workflows should include inputs.item_number fallback",
906+
},
853907
}
854908

855909
for _, tt := range tests {

0 commit comments

Comments
 (0)