-
Notifications
You must be signed in to change notification settings - Fork 528
Import and merge on.bots from shared workflows #57968
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5b4a8fd
8678746
1ed23d7
25200a8
766b3e5
c9345e3
32ff552
e093a66
17816f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # ADR-57968: Import Bot Allowlists from `on.bots` | ||
|
|
||
| **Date**: 2026-09-02 | ||
| **Status**: Draft | ||
| **Deciders**: pelikhan, adr-writer agent | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| This pull request fixes how shared workflows contribute bot allowlists during import processing. The PR description states that imports incorrectly read `bots` from the top level even though the supported frontmatter structure now stores bot allowlists under `on.bots`. The diff updates the import field extractor to read `bots` from the `on` section, updates schema coverage to reject unsupported top-level `bots`, and revises workflow and codemod tests around imported-only, merged, and overlapping allowlists. Because this changes how imported workflow activation metadata is interpreted and migrated, the behavior should be documented explicitly. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will treat `on.bots` as the canonical location for bot allowlists in shared and main workflows, and imported workflows will contribute their bot allowlists by reading from that nested field. We will merge imported and importing workflow allowlists by preserving first-seen order and removing duplicates. We will also update the legacy codemod so that when both top-level `bots` and `on.bots` exist, it consolidates them into a single `on.bots` entry instead of leaving conflicting representations. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Continue Reading Imported Bots from Top-Level `bots` | ||
|
|
||
| Keep the existing importer behavior and continue extracting bot allowlists from a top-level `bots` field. | ||
|
|
||
| This was considered because it would avoid changing the importer and keep older expectations intact. It was not chosen because the PR evidence shows top-level `bots` is no longer a supported frontmatter field, so continuing to read it from imports would preserve incorrect behavior and make shared workflows inconsistent with the validated schema. | ||
|
|
||
| #### Alternative 2: Require Main Workflows to Duplicate Imported Bot Allowlists Manually | ||
|
|
||
| Do not merge bot allowlists from imported workflows and instead require each importing workflow to restate every allowed bot in its own frontmatter. | ||
|
|
||
| This was considered because it reduces implicit behavior in the importer. It was not chosen because the regression tests in this PR explicitly cover imported-only and combined allowlists, showing that shared workflows are expected to contribute bot activation metadata and that manual duplication would be repetitive and error-prone. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - Imported shared workflows now contribute bot allowlists from the supported `on.bots` field, matching the documented schema. | ||
| - Combined bot allowlists are deterministic because merge order is preserved and duplicates are removed. | ||
| - Legacy workflow migration becomes safer because the codemod consolidates dual representations into one canonical `on.bots` field. | ||
|
|
||
| #### Negative | ||
| - Import behavior now depends on nested-field extraction, which adds some implementation complexity in parser and codemod logic. | ||
| - Workflows or tests that still assume top-level `bots` is accepted must be updated to the canonical nested form. | ||
| - The codemod emits JSON-style inline bot arrays when merging, which may alter formatting compared with the original YAML layout. | ||
|
|
||
| #### Neutral | ||
| - Regression coverage now focuses on imported-only, merged, and overlapping allowlist cases. | ||
| - Schema validation and import extraction become more tightly coupled around the canonical `on.bots` structure. | ||
| - The change affects activation metadata handling rather than the compiled workflow steps themselves. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "strings" | ||
|
|
||
| "github.com/github/gh-aw/pkg/logger" | ||
|
|
@@ -10,7 +11,7 @@ var botsCodemodLog = logger.New("cli:codemod_bots") | |
|
|
||
| // getBotsToOnBotsCodemod creates a codemod for moving top-level 'bots' to 'on.bots' | ||
| func getBotsToOnBotsCodemod() Codemod { | ||
| return newMoveTopLevelKeyToOnBlockCodemod(moveToOnBlockConfig{ | ||
| codemod := newMoveTopLevelKeyToOnBlockCodemod(moveToOnBlockConfig{ | ||
| ID: "bots-to-on-bots", | ||
| Name: "Move bots to on.bots", | ||
| Description: "Moves the top-level 'bots' field to 'on.bots' as per the new frontmatter structure", | ||
|
|
@@ -21,4 +22,262 @@ func getBotsToOnBotsCodemod() Codemod { | |
| }, | ||
| Log: botsCodemodLog, | ||
| }) | ||
| baseApply := codemod.Apply | ||
| codemod.Apply = func(content string, frontmatter map[string]any) (string, bool, error) { | ||
| topBots, hasTopBots := frontmatter["bots"] | ||
| onMap, hasOnMap := frontmatter["on"].(map[string]any) | ||
| onBots, hasOnBots := onMap["bots"] | ||
| if !hasTopBots || !hasOnMap || !hasOnBots { | ||
| return baseApply(content, frontmatter) | ||
| } | ||
|
|
||
| mergedBots, ok := mergeLegacyBots(onBots, topBots) | ||
| if !ok { | ||
| return content, false, nil | ||
| } | ||
| return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { | ||
| return mergeLegacyBotsLines(lines, mergedBots) | ||
| }) | ||
| } | ||
| return codemod | ||
| } | ||
|
|
||
| func mergeLegacyBots(onBots, topBots any) ([]string, bool) { | ||
| merged := make([]string, 0) | ||
| seen := make(map[string]struct{}) | ||
| for _, value := range []any{onBots, topBots} { | ||
| bots, ok := value.([]any) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| for _, botValue := range bots { | ||
| bot, ok := botValue.(string) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| if _, exists := seen[bot]; !exists { | ||
| seen[bot] = struct{}{} | ||
| merged = append(merged, bot) | ||
| } | ||
| } | ||
| } | ||
| return merged, true | ||
| } | ||
|
|
||
| func mergeLegacyBotsLines(lines []string, bots []string) ([]string, bool) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pkg/cli/codemod_bots.go:67-124: yagni: 60-line custom YAML block finder/rewriter for a single merge. The existing plus a tiny post-transform would avoid introducing a second parser and JSON serialization path.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pkg/cli/codemod_bots.go:67-124: yagni: 60-line custom YAML block finder/rewriter for a single
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] 💡 Suggested testcontent := `---
on:
bots:
- dependabot
issues:
types: [opened]
bots:
- renovate
---
`Verify the merged @copilot please address this. |
||
| topBotsStart, topBotsEnd := findBotsBlock(lines, 0, len(lines), 0, false) | ||
| onStart := -1 | ||
| for i, line := range lines { | ||
| if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), "on:") { | ||
| onStart = i | ||
| break | ||
| } | ||
| } | ||
| if topBotsStart == -1 || onStart == -1 { | ||
| return lines, false | ||
| } | ||
|
|
||
| onEnd := len(lines) | ||
| for i := onStart + 1; i < len(lines); i++ { | ||
| if isTopLevelKey(lines[i]) { | ||
| onEnd = i | ||
| break | ||
| } | ||
| } | ||
| onBotsStart, onBotsEnd := findBotsBlock(lines, onStart+1, onEnd, len(getIndentation(lines[onStart])), true) | ||
| if onBotsStart == -1 { | ||
| return lines, false | ||
| } | ||
| onBotsIndent := getIndentation(lines[onBotsStart]) | ||
| topBlock := lines[topBotsStart : topBotsEnd+1] | ||
| onBlock := lines[onBotsStart : onBotsEnd+1] | ||
| comments := append(collectComments(lines, topBotsStart, topBotsEnd), collectComments(lines, onBotsStart, onBotsEnd)...) | ||
| itemComments := mergeBotItemComments(topBlock, onBlock) | ||
| isInline := isInlineBotsValue(lines[onBotsStart]) && len(itemComments) == 0 && len(comments) == 0 | ||
| declaration := buildBotsDeclaration(onBotsIndent, bots, itemComments, lines[onBotsStart], isInline) | ||
| if len(comments) > 0 { | ||
| declaration = append(append([]string{}, comments...), declaration...) | ||
| } | ||
|
|
||
| result := make([]string, 0, len(lines)) | ||
| for i, line := range lines { | ||
| if (i >= topBotsStart && i <= topBotsEnd) || (i >= onBotsStart && i <= onBotsEnd) { | ||
| continue | ||
|
Comment on lines
+104
to
+105
|
||
| } | ||
| result = append(result, line) | ||
| if i == onStart { | ||
| result = append(result, declaration...) | ||
| } | ||
| } | ||
| return result, true | ||
| } | ||
|
|
||
| func buildBotsDeclaration(indent string, bots []string, itemComments map[string]string, originalLine string, inline bool) []string { | ||
| if inline { | ||
| encodedBots, err := json.Marshal(bots) | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| line := indent + "bots: " + string(encodedBots) | ||
| if comment := trailingComment(originalLine); comment != "" { | ||
| line += " " + comment | ||
| } | ||
| return []string{line} | ||
| } | ||
| out := []string{indent + "bots:"} | ||
| for _, bot := range bots { | ||
| line := indent + " - " + bot | ||
| if comment, ok := itemComments[bot]; ok && comment != "" { | ||
| line += " " + comment | ||
| } | ||
| out = append(out, line) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| func trailingComment(line string) string { | ||
| trimmed := strings.TrimSpace(line) | ||
| inSingleQuoted := false | ||
| inDoubleQuoted := false | ||
| for i := range trimmed { | ||
| ch := trimmed[i] | ||
| switch ch { | ||
| case '\'': | ||
| if !inDoubleQuoted { | ||
| inSingleQuoted = !inSingleQuoted | ||
| } | ||
| case '"': | ||
| if !inSingleQuoted { | ||
| inDoubleQuoted = !inDoubleQuoted | ||
| } | ||
| case '#': | ||
| if !inSingleQuoted && !inDoubleQuoted { | ||
| return strings.TrimSpace(trimmed[i:]) | ||
| } | ||
| } | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| func collectComments(lines []string, start, end int) []string { | ||
| comments := make([]string, 0) | ||
| for i := start; i <= end; i++ { | ||
| if i >= len(lines) { | ||
| break | ||
| } | ||
| trimmed := strings.TrimSpace(lines[i]) | ||
| if trimmed == "" || strings.HasPrefix(trimmed, "#") { | ||
| comments = append(comments, lines[i]) | ||
| } | ||
| } | ||
| return comments | ||
| } | ||
|
|
||
| func mergeBotItemComments(blocks ...[]string) map[string]string { | ||
| commentsByBot := make(map[string]string) | ||
| for _, block := range blocks { | ||
| for i := range block { | ||
| trimmed := strings.TrimSpace(block[i]) | ||
| if strings.HasPrefix(trimmed, "- ") { | ||
| entry := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) | ||
| if entry == "" { | ||
| continue | ||
| } | ||
| key, comment := splitBotEntry(entry) | ||
| if key != "" { | ||
| if _, ok := commentsByBot[key]; !ok && comment != "" { | ||
| commentsByBot[key] = comment | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return commentsByBot | ||
| } | ||
|
|
||
| func splitBotEntry(entry string) (string, string) { | ||
| trimmed := strings.TrimSpace(entry) | ||
| inSingleQuoted := false | ||
| inDoubleQuoted := false | ||
| for i := range trimmed { | ||
| ch := trimmed[i] | ||
| switch ch { | ||
| case '\'': | ||
| if !inDoubleQuoted { | ||
| inSingleQuoted = !inSingleQuoted | ||
| } | ||
| case '"': | ||
| if !inSingleQuoted { | ||
| inDoubleQuoted = !inDoubleQuoted | ||
| } | ||
| case '#': | ||
| if !inSingleQuoted && !inDoubleQuoted { | ||
| return strings.TrimSpace(trimmed[:i]), strings.TrimSpace(trimmed[i:]) | ||
| } | ||
| } | ||
| } | ||
| return trimmed, "" | ||
| } | ||
|
|
||
| func isInlineBotsValue(line string) bool { | ||
| trimmed := strings.TrimSpace(line) | ||
| return strings.HasPrefix(trimmed, "bots:") && !strings.Contains(trimmed, "\n") && !strings.Contains(trimmed, "- ") | ||
| } | ||
|
|
||
| func findBotsBlock(lines []string, start, end, indent int, nested bool) (int, int) { | ||
| var directChildIndent = -1 | ||
| for i := start; i < end; i++ { | ||
| line := lines[i] | ||
| trimmed := strings.TrimSpace(line) | ||
| if trimmed == "" || strings.HasPrefix(trimmed, "#") { | ||
| continue | ||
| } | ||
| lineIndent := len(getIndentation(line)) | ||
| if nested { | ||
| if lineIndent <= indent { | ||
| continue | ||
| } | ||
| if directChildIndent == -1 { | ||
| directChildIndent = lineIndent | ||
| } | ||
| if lineIndent > directChildIndent { | ||
| continue | ||
| } | ||
| if lineIndent != directChildIndent { | ||
| continue | ||
| } | ||
| } else if lineIndent != indent { | ||
| continue | ||
| } | ||
| if !strings.HasPrefix(trimmed, "bots:") { | ||
| continue | ||
| } | ||
| blockEnd := i | ||
| for j := i + 1; j < end; j++ { | ||
| next := strings.TrimSpace(lines[j]) | ||
| if next == "" || strings.HasPrefix(next, "#") { | ||
| blockEnd = j | ||
| continue | ||
| } | ||
| nextIndent := len(getIndentation(lines[j])) | ||
| if nextIndent <= lineIndent && isYAMLKeyLike(lines[j]) { | ||
| break | ||
| } | ||
| if nextIndent > lineIndent { | ||
| blockEnd = j | ||
| continue | ||
| } | ||
| break | ||
| } | ||
| return i, blockEnd | ||
| } | ||
| return -1, -1 | ||
| } | ||
|
|
||
| func isYAMLKeyLike(line string) bool { | ||
| trimmed := strings.TrimSpace(line) | ||
| if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "- ") { | ||
| return false | ||
| } | ||
| return strings.Contains(trimmed, ":") | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd]
mergeLegacyBotsonly handles[]anybot lists; if eitheron.botsor top-levelbotsis a bare scalar string (which the schema likely still permits for single-bot shorthand), it returnsok=falseand the codemod silently no-ops, leaving the legacy top-levelbotsfield unmigrated with no error surfaced to the user.💡 Suggested coverage
Add a case in
TestBotsToOnBotsCodemod_*whereon.botsor top-levelbotsis a scalar string (e.g.bots: renovate[bot]) to confirm the intended fallback behavior, and consider normalizing scalars to a single-element list before merging so the migration still succeeds instead of silently skipping.@copilot please address this.