From 0ef350cd8defd2d738589a2353a1aa50592c971f Mon Sep 17 00:00:00 2001 From: Bob Killen Date: Wed, 2 Sep 2026 13:25:57 -0500 Subject: [PATCH 1/3] Add gov generator Signed-off-by: Bob Killen --- generator/README.md | 45 +++ generator/generator.go | 230 +++++++++++++ generator/generator_test.go | 638 ++++++++++++++++++++++++++++++++++++ generator/go.mod | 5 + generator/go.sum | 4 + generator/group_readme.tmpl | 62 ++++ 6 files changed, 984 insertions(+) create mode 100644 generator/README.md create mode 100644 generator/generator.go create mode 100644 generator/generator_test.go create mode 100644 generator/go.mod create mode 100644 generator/go.sum create mode 100644 generator/group_readme.tmpl diff --git a/generator/README.md b/generator/README.md new file mode 100644 index 0000000..4ce247b --- /dev/null +++ b/generator/README.md @@ -0,0 +1,45 @@ +# End User Technical Advisory Board + +The CNCF End User Technical Advisory Board (TAB) acts as a vital voice of end users within the CNCF community. It plays a key role in advancing topics of concern to end users, enhancing visibility into end user adoption of CNCF projects, and raising awareness about the needs and perspectives of end users in the cloud native ecosystem. + + + +## Leadership + +| Name | GitHub | Company | Role | +|------|--------|---------|------| +| [Ricardo Rocha](https://github.com/rochaporto) | @rochaporto | CERN | Chair | +| [Joseph Sandoval](https://github.com/jrsapi) | @jrsapi | Adobe Inc | Vice Chair | + + +## Members + +| Name | GitHub | Company | +|------|--------|---------| +| [Alolita Sharma](https://github.com/alolita) | @alolita | Apple | +| [Xu Wang](https://github.com/gnawux) | @gnawux | Ant Group | +| Ben Somogyi | @ | Lockheed Martin | +| [Kenta Tada](https://github.com/KentaTada) | @KentaTada | Toyota | +| [Ahmed Bebars](https://github.com/abebars) | @abebars | The New York Times | +| [Mike Bowen](https://github.com/michael-bowen-sc) | @michael-bowen-sc | Blackrock | +| [Chad Beaudin](https://github.com/chadbeaudin) | @chadbeaudin | Boeing | +| [Katie Gamanji](https://github.com/kgamanji) | @kgamanji | Apple | + + + +## Contact + +* Slack: [#tab](https://cloud-native.slack.com/archives/C05T9P1HXR6) +* Mailing List: [cncf-enduser@lists.cncf.io](mailto:cncf-enduser@lists.cncf.io) +* GitHub Teams: +* [@cncf/tab](https://github.com/orgs/cncf/teams/tab) — End User Technical Advisory Board members + + +## Meetings + +* TAB Public Meeting — 3rd Monday of every month at 8AM PT — [Join](https://zoom.us/j/96509520391) + * [Meeting Notes](https://github.com/orgs/cncf/projects/60) + + + + diff --git a/generator/generator.go b/generator/generator.go new file mode 100644 index 0000000..4eee86b --- /dev/null +++ b/generator/generator.go @@ -0,0 +1,230 @@ +package main + +import ( + "bytes" + "fmt" + "gopkg.in/yaml.v2" + "log" + "os" + "path/filepath" + "strings" + "text/template" +) + +const ( + govYamlFile = "gov.yaml" + indexFilename = "README.md" + beginCustomMarkdown = "" + endCustomMarkdown = "" +) + +// GitHubTeam represents a GitHub team reference. +type GitHubTeam struct { + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` +} + +// Term holds start and end dates. +type Term struct { + Start string `yaml:"start,omitempty"` + End string `yaml:"end,omitempty"` +} + +// Person holds person data. +type Person struct { + Name string `yaml:"name"` + GitHub string `yaml:"github"` + Slack string `yaml:"slack,omitempty"` + Seat string `yaml:"seat,omitempty"` + Role string `yaml:"role,omitempty"` + Company string `yaml:"company,omitempty"` + Term Term `yaml:"term,omitempty"` +} + +// Meeting holds meeting data. +type Meeting struct { + Description string `yaml:"description"` + RecordingsURL string `yaml:"recordings_url,omitempty"` + MeetingURL string `yaml:"meeting_url,omitempty"` + MeetingNotesURL string `yaml:"meeting_notes_url,omitempty"` +} + +// Contact holds contact information. +type Contact struct { + Slack string `yaml:"slack,omitempty"` + SlackChannel string `yaml:"slack_channel,omitempty"` + MailingList string `yaml:"mailing_list,omitempty"` + GitHubTeams []GitHubTeam `yaml:"github_teams,omitempty"` + Liaison []Person `yaml:"liaison,omitempty"` +} + +// Group represents a TAB group or User Group. +type Group struct { + Dir string `yaml:"dir"` + Name string `yaml:"name"` + Leadership []Person `yaml:"leadership,omitempty"` + Members []Person `yaml:"members,omitempty"` + Emeritus []Person `yaml:"emeritus,omitempty"` + MissionStatement string `yaml:"mission_statement,omitempty"` + Meetings []Meeting `yaml:"meetings,omitempty"` + Contact Contact `yaml:"contact,omitempty"` + CharterLink string `yaml:"charter_link,omitempty"` + Label string `yaml:"label,omitempty"` +} + +// Config is the top-level governance configuration. +type Config struct { + TAB []Group `yaml:"tab"` + UserGroups []Group `yaml:"user_groups"` +} + +// TemplateData extends Group with computed fields for template rendering. +type TemplateData struct { + Group + GroupType string // "tab" or "user-group" + RepoBaseURL string +} + +var ( + templateDir = "." +) + +func main() { + configPath := filepath.Join("..", govYamlFile) + + data, err := os.ReadFile(configPath) + if err != nil { + log.Fatalf("Failed to read %s: %v", configPath, err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + log.Fatalf("Failed to parse YAML: %v", err) + } + + // Load templates. + funcMap := template.FuncMap{ + "lower": strings.ToLower, + "replace": strings.ReplaceAll, + "trimSpace": strings.TrimSpace, + "githubLink": func(p Person) string { + if p.GitHub != "" { + return fmt.Sprintf("[%s](https://github.com/%s)", p.Name, p.GitHub) + } + return p.Name + }, + } + + groupTmpl := loadTemplate("group_readme.tmpl", funcMap) + + repoBaseURL := "https://github.com/cncf/tab" + + // Process TAB groups. + processGroups(config.TAB, "tab", "..", repoBaseURL, groupTmpl) + + // Process User Groups. + processGroups(config.UserGroups, "user-group", filepath.Join("..", "user-groups"), repoBaseURL, groupTmpl) + + log.Println("README files have been generated successfully.") +} + +func processGroups(groups []Group, groupType, baseDir, repoBaseURL string, groupTmpl *template.Template) { + if err := ensureDir(baseDir); err != nil { + log.Fatalf("Failed to create base directory %s: %v", baseDir, err) + } + + for _, group := range groups { + if group.Dir == "" { + group.Dir = slugify(group.Name) + } + + groupDir := filepath.Join(baseDir, group.Dir) + if err := ensureDir(groupDir); err != nil { + log.Fatalf("Failed to create directory for %s: %v", group.Name, err) + } + + td := TemplateData{ + Group: group, + GroupType: groupType, + RepoBaseURL: repoBaseURL, + } + + if err := writeTemplate(groupTmpl, td, filepath.Join(groupDir, indexFilename)); err != nil { + log.Fatalf("Failed to generate README for %s: %v", group.Name, err) + } + } +} + +func loadTemplate(name string, funcMap template.FuncMap) *template.Template { + path := filepath.Join(templateDir, name) + content, err := os.ReadFile(path) + if err != nil { + log.Fatalf("Failed to read template %s: %v", path, err) + } + tmpl, err := template.New(name).Funcs(funcMap).Parse(string(content)) + if err != nil { + log.Fatalf("Failed to parse template %s: %v", name, err) + } + return tmpl +} + +func writeTemplate(tmpl *template.Template, data interface{}, outPath string) error { + customContent, err := getExistingCustomContent(outPath) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("template execution failed for %s: %w", outPath, err) + } + + buf.WriteString(beginCustomMarkdown) + if customContent == "" { + buf.WriteByte('\n') + } else { + buf.WriteString(customContent) + } + buf.WriteString(endCustomMarkdown) + buf.WriteByte('\n') + + return os.WriteFile(outPath, buf.Bytes(), 0o644) +} + +func getExistingCustomContent(path string) (string, error) { + content, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read existing custom content from %s: %w", path, err) + } + + beginCount := strings.Count(string(content), beginCustomMarkdown) + endCount := strings.Count(string(content), endCustomMarkdown) + if beginCount == 0 && endCount == 0 { + return "", nil + } + if beginCount != 1 || endCount != 1 { + return "", fmt.Errorf("invalid custom content markers in %s: expected one begin and one end marker", path) + } + + begin := strings.Index(string(content), beginCustomMarkdown) + end := strings.Index(string(content), endCustomMarkdown) + if end < begin { + return "", fmt.Errorf("invalid custom content markers in %s: end marker appears before begin marker", path) + } + + return string(content[begin+len(beginCustomMarkdown) : end]), nil +} + +func slugify(name string) string { + s := strings.ToLower(name) + s = strings.ReplaceAll(s, " ", "-") + s = strings.ReplaceAll(s, "/", "-") + return s +} + +func ensureDir(dirPath string) error { + return os.MkdirAll(dirPath, 0o755) +} diff --git a/generator/generator_test.go b/generator/generator_test.go new file mode 100644 index 0000000..6519dee --- /dev/null +++ b/generator/generator_test.go @@ -0,0 +1,638 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "text/template" + + "gopkg.in/yaml.v2" +) + +// --------------------------------------------------------------------------- +// slugify +// --------------------------------------------------------------------------- + +func TestSlugify(t *testing.T) { + tests := []struct { + input, want string + }{ + {"Developer Experience", "developer-experience"}, + {"Public Sector", "public-sector"}, + {"Already-Lowercase", "already-lowercase"}, + {"With / Slash", "with---slash"}, + {"simple", "simple"}, + {"", ""}, + } + for _, tt := range tests { + if got := slugify(tt.input); got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// --------------------------------------------------------------------------- +// ensureDir +// --------------------------------------------------------------------------- + +func TestEnsureDir(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, "a", "b", "c") + if err := ensureDir(nested); err != nil { + t.Fatalf("ensureDir failed: %v", err) + } + info, err := os.Stat(nested) + if err != nil { + t.Fatalf("directory not created: %v", err) + } + if !info.IsDir() { + t.Fatal("expected directory") + } + + // idempotent + if err := ensureDir(nested); err != nil { + t.Fatalf("ensureDir not idempotent: %v", err) + } +} + +// --------------------------------------------------------------------------- +// YAML parsing (Config) +// --------------------------------------------------------------------------- + +func TestConfigParsing(t *testing.T) { + yamlData := ` +tab: + - name: Test TAB + dir: test-tab + mission_statement: Test mission + leadership: + - name: Alice + github: alice + slack: alice-slack + company: ACME + role: Chair + members: + - name: MemberOne + github: member1 + company: BigCo + - name: MemberTwo + github: member2 + slack: member2-slack + company: SmallCo + emeritus: + - name: Bob + github: bob + meetings: + - description: Weekly sync + meeting_url: https://example.com/meet + meeting_notes_url: https://example.com/notes + recordings_url: https://example.com/recordings + contact: + slack: https://slack.example.com + slack_channel: test-tab + mailing_list: tab@example.com + github_teams: + - name: tab-team + description: The TAB team + liaison: + - name: Charlie + github: charlie +user_groups: + - name: UG One + dir: ug-one + leadership: [] +` + var config Config + if err := yaml.Unmarshal([]byte(yamlData), &config); err != nil { + t.Fatalf("failed to parse YAML: %v", err) + } + + if len(config.TAB) != 1 { + t.Fatalf("expected 1 TAB group, got %d", len(config.TAB)) + } + tab := config.TAB[0] + if tab.Name != "Test TAB" { + t.Errorf("tab name = %q, want %q", tab.Name, "Test TAB") + } + if tab.Dir != "test-tab" { + t.Errorf("tab dir = %q, want %q", tab.Dir, "test-tab") + } + if len(tab.Leadership) != 1 { + t.Fatalf("expected 1 leader, got %d", len(tab.Leadership)) + } + if tab.Leadership[0].GitHub != "alice" { + t.Errorf("leader github = %q", tab.Leadership[0].GitHub) + } + if tab.Leadership[0].Role != "Chair" { + t.Errorf("leader role = %q, want Chair", tab.Leadership[0].Role) + } + if tab.Leadership[0].Slack != "alice-slack" { + t.Errorf("leader slack = %q, want alice-slack", tab.Leadership[0].Slack) + } + if len(tab.Members) != 2 { + t.Fatalf("expected 2 members, got %d", len(tab.Members)) + } + if tab.Members[0].GitHub != "member1" { + t.Errorf("member[0] github = %q", tab.Members[0].GitHub) + } + if tab.Members[1].Slack != "member2-slack" { + t.Errorf("member[1] slack = %q, want member2-slack", tab.Members[1].Slack) + } + if len(tab.Emeritus) != 1 { + t.Errorf("expected 1 emeritus, got %d", len(tab.Emeritus)) + } + if tab.Emeritus[0].GitHub != "bob" { + t.Errorf("emeritus github = %q", tab.Emeritus[0].GitHub) + } + if len(tab.Meetings) != 1 { + t.Fatalf("expected 1 meeting, got %d", len(tab.Meetings)) + } + if tab.Meetings[0].RecordingsURL != "https://example.com/recordings" { + t.Errorf("recordings url wrong") + } + if len(tab.Contact.GitHubTeams) != 1 { + t.Errorf("expected 1 github team") + } + if len(tab.Contact.Liaison) != 1 { + t.Errorf("expected 1 liaison") + } + + if len(config.UserGroups) != 1 { + t.Fatalf("expected 1 user group, got %d", len(config.UserGroups)) + } +} + +func TestConfigParsing_PersonSlackAndTerm(t *testing.T) { + yamlData := ` +tab: + - name: Term Test + dir: term-test + leadership: + - name: Leader + github: leader + slack: leader-slack + company: Co + term: + start: "2025-01" + end: "2027-01" +` + var config Config + if err := yaml.Unmarshal([]byte(yamlData), &config); err != nil { + t.Fatalf("failed to parse YAML: %v", err) + } + p := config.TAB[0].Leadership[0] + if p.Slack != "leader-slack" { + t.Errorf("slack = %q, want leader-slack", p.Slack) + } + if p.Term.Start != "2025-01" { + t.Errorf("term.start = %q, want 2025-01", p.Term.Start) + } + if p.Term.End != "2027-01" { + t.Errorf("term.end = %q, want 2027-01", p.Term.End) + } +} + +func TestConfigParsing_EmptyFields(t *testing.T) { + yamlData := ` +tab: + - name: Minimal + dir: minimal +user_groups: [] +` + var config Config + if err := yaml.Unmarshal([]byte(yamlData), &config); err != nil { + t.Fatalf("failed to parse YAML: %v", err) + } + if len(config.TAB) != 1 { + t.Fatalf("expected 1 TAB group, got %d", len(config.TAB)) + } + tab := config.TAB[0] + if tab.Leadership != nil { + t.Errorf("expected nil leadership, got %v", tab.Leadership) + } + if tab.Members != nil { + t.Errorf("expected nil members, got %v", tab.Members) + } + if tab.Emeritus != nil { + t.Errorf("expected nil emeritus, got %v", tab.Emeritus) + } + if len(config.UserGroups) != 0 { + t.Errorf("expected 0 user groups, got %d", len(config.UserGroups)) + } +} + +// --------------------------------------------------------------------------- +// writeTemplate +// --------------------------------------------------------------------------- + +func TestWriteTemplate(t *testing.T) { + tmpl := template.Must(template.New("test").Parse("Hello {{ .Name }}!")) + dir := t.TempDir() + out := filepath.Join(dir, "out.txt") + + data := struct{ Name string }{"World"} + if err := writeTemplate(tmpl, data, out); err != nil { + t.Fatalf("writeTemplate failed: %v", err) + } + + got, _ := os.ReadFile(out) + want := "Hello World!" + beginCustomMarkdown + "\n" + endCustomMarkdown + "\n" + if string(got) != want { + t.Errorf("got %q, want %q", string(got), want) + } +} + +func TestWriteTemplateError(t *testing.T) { + tmpl := template.Must(template.New("bad").Parse("{{ .Missing.Field }}")) + dir := t.TempDir() + out := filepath.Join(dir, "out.txt") + + err := writeTemplate(tmpl, struct{}{}, out) + if err == nil { + t.Fatal("expected error from template execution") + } +} + +// --------------------------------------------------------------------------- +// processGroups (integration-style) +// --------------------------------------------------------------------------- + +func newTestTemplate(t *testing.T) *template.Template { + t.Helper() + funcMap := template.FuncMap{ + "lower": strings.ToLower, + "replace": strings.ReplaceAll, + "trimSpace": strings.TrimSpace, + "githubLink": func(p Person) string { + if p.GitHub != "" { + return "[" + p.Name + "](https://github.com/" + p.GitHub + ")" + } + return p.Name + }, + } + + groupTmplStr := `# {{ .Name }} +{{ if .Leadership }} +## Leadership +{{ range .Leadership -}} +* {{ githubLink . }} ({{ .Company }}) +{{ end -}} +{{ end }} +{{ if .Members }} +## Members +{{ range .Members -}} +* {{ githubLink . }} +{{ end -}} +{{ end }} +{{ if .Emeritus }} +## Emeritus +{{ range .Emeritus -}} +* {{ githubLink . }} +{{ end -}} +{{ end }} +` + + return template.Must(template.New("group").Funcs(funcMap).Parse(groupTmplStr)) +} + +func TestProcessGroups_Basic(t *testing.T) { + dir := t.TempDir() + groupTmpl := newTestTemplate(t) + + groups := []Group{ + { + Name: "My Group", + Dir: "my-group", + Leadership: []Person{ + {Name: "Alice", GitHub: "alice", Company: "ACME", Role: "Chair"}, + }, + }, + } + + processGroups(groups, "tab", dir, "https://github.com/cncf/tab", groupTmpl) + + readme := filepath.Join(dir, "my-group", "README.md") + data, err := os.ReadFile(readme) + if err != nil { + t.Fatalf("README not created: %v", err) + } + content := string(data) + if !strings.Contains(content, "# My Group") { + t.Error("missing group name in output") + } + if !strings.Contains(content, "[Alice](https://github.com/alice)") { + t.Error("missing leader link in output") + } + if !strings.Contains(content, beginCustomMarkdown) { + t.Error("missing custom content markers") + } +} + +func TestProcessGroups_WithMembers(t *testing.T) { + dir := t.TempDir() + groupTmpl := newTestTemplate(t) + + groups := []Group{ + { + Name: "Full Group", + Dir: "full-group", + Leadership: []Person{ + {Name: "Lead", GitHub: "lead", Company: "LeadCo", Role: "Chair"}, + }, + Members: []Person{ + {Name: "MemberA", GitHub: "membera", Company: "CorpA"}, + {Name: "MemberB", GitHub: "memberb", Company: "CorpB"}, + }, + Emeritus: []Person{ + {Name: "OldTimer", GitHub: "oldtimer", Company: "PastCo"}, + }, + }, + } + + processGroups(groups, "tab", dir, "", groupTmpl) + + data, err := os.ReadFile(filepath.Join(dir, "full-group", "README.md")) + if err != nil { + t.Fatalf("README not created: %v", err) + } + content := string(data) + + checks := []string{ + "[Lead](https://github.com/lead)", + "[MemberA](https://github.com/membera)", + "[MemberB](https://github.com/memberb)", + "[OldTimer](https://github.com/oldtimer)", + "## Leadership", + "## Members", + "## Emeritus", + } + for _, c := range checks { + if !strings.Contains(content, c) { + t.Errorf("output missing %q", c) + } + } +} + +func TestProcessGroups_AutoDir(t *testing.T) { + dir := t.TempDir() + groupTmpl := newTestTemplate(t) + + groups := []Group{ + { + Name: "Auto Dir Group", + }, + } + + processGroups(groups, "user-group", dir, "", groupTmpl) + + readme := filepath.Join(dir, "auto-dir-group", "README.md") + if _, err := os.Stat(readme); err != nil { + t.Fatalf("auto-dir README not created: %v", err) + } +} + +func TestProcessGroups_PreservesExistingCustomContent(t *testing.T) { + dir := t.TempDir() + groupTmpl := newTestTemplate(t) + + // Pre-populate a README with custom content between markers. + groupDir := filepath.Join(dir, "my-group") + os.MkdirAll(groupDir, 0o755) + existing := "# Old\n" + beginCustomMarkdown + "\nMy special notes\n" + endCustomMarkdown + "\n" + os.WriteFile(filepath.Join(groupDir, "README.md"), []byte(existing), 0o644) + + groups := []Group{ + { + Name: "My Group", + Dir: "my-group", + }, + } + + processGroups(groups, "tab", dir, "", groupTmpl) + + data, _ := os.ReadFile(filepath.Join(groupDir, "README.md")) + content := string(data) + if !strings.Contains(content, "My special notes") { + t.Error("custom content should be preserved across regeneration") + } + if !strings.Contains(content, "# My Group") { + t.Error("group name should be updated") + } + if !strings.Contains(content, beginCustomMarkdown) { + t.Error("custom content markers should still be present") + } +} + +func TestGetExistingCustomContent_InvalidMarkers(t *testing.T) { + tests := []struct { + name string + content string + }{ + {"missing end", beginCustomMarkdown}, + {"missing begin", endCustomMarkdown}, + {"end before begin", endCustomMarkdown + beginCustomMarkdown}, + {"duplicate pair", beginCustomMarkdown + endCustomMarkdown + beginCustomMarkdown + endCustomMarkdown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "README.md") + if err := os.WriteFile(path, []byte(tt.content), 0o644); err != nil { + t.Fatal(err) + } + if _, err := getExistingCustomContent(path); err == nil { + t.Fatal("expected invalid marker error") + } + }) + } +} + +func TestWriteTemplate_PreservesCustomContentVerbatim(t *testing.T) { + path := filepath.Join(t.TempDir(), "README.md") + customContent := "\n## Maintainer notes\n\nKeep **this formatting** exactly.\n" + existing := "Generated content\n" + beginCustomMarkdown + customContent + endCustomMarkdown + "\n" + if err := os.WriteFile(path, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + tmpl := template.Must(template.New("test").Parse("Generated content\n")) + if err := writeTemplate(tmpl, struct{}{}, path); err != nil { + t.Fatalf("writeTemplate failed: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "Generated content\n" + beginCustomMarkdown + customContent + endCustomMarkdown + "\n" + if string(got) != want { + t.Errorf("generated README = %q, want %q", got, want) + } +} + +func TestProcessGroups_MultipleGroups(t *testing.T) { + dir := t.TempDir() + groupTmpl := newTestTemplate(t) + + groups := []Group{ + {Name: "Group A", Dir: "group-a"}, + {Name: "Group B", Dir: "group-b"}, + {Name: "Group C"}, // auto-dir + } + + processGroups(groups, "user-group", dir, "", groupTmpl) + + for _, d := range []string{"group-a", "group-b", "group-c"} { + readme := filepath.Join(dir, d, "README.md") + if _, err := os.Stat(readme); err != nil { + t.Errorf("README not created for %s: %v", d, err) + } + } +} + +// --------------------------------------------------------------------------- +// Template rendering with real template file +// --------------------------------------------------------------------------- + +func TestRealTemplate(t *testing.T) { + if _, err := os.Stat("group_readme.tmpl"); err != nil { + t.Skipf("skipping: template not found (run tests from generator/)") + } + + funcMap := template.FuncMap{ + "lower": strings.ToLower, + "replace": strings.ReplaceAll, + "trimSpace": strings.TrimSpace, + "githubLink": func(p Person) string { + if p.GitHub != "" { + return "[" + p.Name + "](https://github.com/" + p.GitHub + ")" + } + return p.Name + }, + } + + tmpl := template.Must(template.New("group_readme.tmpl").Funcs(funcMap).ParseFiles("group_readme.tmpl")) + + td := TemplateData{ + Group: Group{ + Name: "Test Group", + MissionStatement: "We test things.", + Leadership: []Person{ + {Name: "Alice", GitHub: "alice", Company: "TestCo", Role: "Chair"}, + {Name: "ViceAlice", GitHub: "vicealice", Company: "TestCo", Role: "Vice Chair"}, + }, + Members: []Person{ + {Name: "MemberX", GitHub: "memberx", Company: "CorpX"}, + }, + Emeritus: []Person{ + {Name: "OldBob", GitHub: "oldbob", Company: "PastCo"}, + }, + Contact: Contact{ + Slack: "https://slack.example.com/channel", + SlackChannel: "test-group", + MailingList: "test@example.com", + GitHubTeams: []GitHubTeam{{Name: "test-team", Description: "Test team"}}, + Liaison: []Person{{Name: "Liam", GitHub: "liam"}}, + }, + Meetings: []Meeting{ + { + Description: "Weekly sync", + MeetingURL: "https://example.com/meet", + MeetingNotesURL: "https://example.com/notes", + RecordingsURL: "https://example.com/rec", + }, + }, + }, + GroupType: "tab", + RepoBaseURL: "https://github.com/cncf/tab", + } + + dir := t.TempDir() + out := filepath.Join(dir, "README.md") + if err := writeTemplate(tmpl, td, out); err != nil { + t.Fatalf("template execution failed: %v", err) + } + + data, _ := os.ReadFile(out) + content := string(data) + + checks := []string{ + "# Test Group", + "We test things.", + "[Alice](https://github.com/alice)", + "@alice", + "TestCo", + "[ViceAlice](https://github.com/vicealice)", + "[MemberX](https://github.com/memberx)", + "[OldBob](https://github.com/oldbob)", + "#test-group", + "test@example.com", + "@cncf/test-team", + "[Liam](https://github.com/liam)", + "Weekly sync", + "https://example.com/meet", + "Meeting Notes", + "Recordings", + beginCustomMarkdown, + endCustomMarkdown, + } + for _, c := range checks { + if !strings.Contains(content, c) { + t.Errorf("output missing %q", c) + } + } +} + +func TestRealTemplate_MinimalGroup(t *testing.T) { + if _, err := os.Stat("group_readme.tmpl"); err != nil { + t.Skipf("skipping: template not found (run tests from generator/)") + } + + funcMap := template.FuncMap{ + "lower": strings.ToLower, + "replace": strings.ReplaceAll, + "trimSpace": strings.TrimSpace, + "githubLink": func(p Person) string { + if p.GitHub != "" { + return "[" + p.Name + "](https://github.com/" + p.GitHub + ")" + } + return p.Name + }, + } + + tmpl := template.Must(template.New("group_readme.tmpl").Funcs(funcMap).ParseFiles("group_readme.tmpl")) + + td := TemplateData{ + Group: Group{ + Name: "Minimal Group", + }, + GroupType: "user-group", + } + + dir := t.TempDir() + out := filepath.Join(dir, "README.md") + if err := writeTemplate(tmpl, td, out); err != nil { + t.Fatalf("template execution failed: %v", err) + } + + data, _ := os.ReadFile(out) + content := string(data) + + if !strings.Contains(content, "# Minimal Group") { + t.Error("missing group name") + } + if !strings.Contains(content, beginCustomMarkdown) { + t.Error("missing custom content markers") + } + // Should NOT contain sections for empty fields + if strings.Contains(content, "## Leadership") { + t.Error("should not render Leadership section when empty") + } + if strings.Contains(content, "## Members") { + t.Error("should not render Members section when empty") + } + if strings.Contains(content, "## Emeritus") { + t.Error("should not render Emeritus section when empty") + } +} diff --git a/generator/go.mod b/generator/go.mod new file mode 100644 index 0000000..57bf4b5 --- /dev/null +++ b/generator/go.mod @@ -0,0 +1,5 @@ +module github.com/cncf/tab/generator + +go 1.27.0 + +require gopkg.in/yaml.v2 v2.4.0 diff --git a/generator/go.sum b/generator/go.sum new file mode 100644 index 0000000..dd0bc19 --- /dev/null +++ b/generator/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/generator/group_readme.tmpl b/generator/group_readme.tmpl new file mode 100644 index 0000000..bbbe24c --- /dev/null +++ b/generator/group_readme.tmpl @@ -0,0 +1,62 @@ +# {{ .Name }} + +{{ if .MissionStatement }}{{ .MissionStatement }}{{ end }} +{{ if .CharterLink }} +[Charter]({{ .CharterLink }}) +{{ end }} +{{ if .Leadership }} +## Leadership + +| Name | GitHub | Company | Role | +|------|--------|---------|------| +{{ range .Leadership -}} +| {{ githubLink . }} | @{{ .GitHub }} | {{ .Company }} | {{ if .Role }}{{ .Role }}{{ else }}Chair{{ end }} | +{{ end -}} +{{ end }} +{{ if .Members }} +## Members + +| Name | GitHub | Company | Role | +|------|--------|---------|------| +{{ range .Members -}} +| {{ githubLink . }} | @{{ .GitHub }} | {{ .Company }} | {{ if .Role }}{{ .Role }}{{ else }}Member{{ end }} | +{{ end -}} +{{ end }} +{{ if .Emeritus }} +## Emeritus + +{{ range .Emeritus -}} +* {{ githubLink . }}{{ if .Company }} ({{ .Company }}){{ end }} +{{ end -}} +{{ end }} +{{ if .Contact.Slack }} +## Contact + +{{ if .Contact.Slack -}} +* Slack: [#{{ .Contact.SlackChannel }}]({{ .Contact.Slack }}) +{{ end -}} +{{ if .Contact.MailingList -}} +* Mailing List: [{{ .Contact.MailingList }}](mailto:{{ .Contact.MailingList }}) +{{ end -}} +{{ if .Contact.GitHubTeams -}} +* GitHub Teams: +{{ range .Contact.GitHubTeams -}} + * [@cncf/{{ .Name }}](https://github.com/orgs/cncf/teams/{{ .Name }}){{ if .Description }} — {{ .Description }}{{ end }} +{{ end -}} +{{ end -}} +{{ if .Contact.Liaison -}} +* TOC Liaison: +{{ range .Contact.Liaison -}} + * {{ githubLink . }} +{{ end -}} +{{ end -}} +{{ end }} +{{ if .Meetings }} +## Meetings + +{{ range .Meetings -}} +* {{ .Description }}{{ if .MeetingURL }} — [Join]({{ .MeetingURL }}){{ end }} +{{ if .MeetingNotesURL }} * [Meeting Notes]({{ .MeetingNotesURL }}){{ end }} +{{ if .RecordingsURL }} * [Recordings]({{ .RecordingsURL }}){{ end }} +{{ end -}} +{{ end }} From 1ca4c6cf947a0ce6d76be3acc3113348216f723f Mon Sep 17 00:00:00 2001 From: Bob Killen Date: Wed, 2 Sep 2026 13:26:34 -0500 Subject: [PATCH 2/3] Add base gov config and initial generator Signed-off-by: Bob Killen --- README.md | 92 +++++++++++++++++++++++-------------------------------- gov.yaml | 76 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 53 deletions(-) create mode 100644 gov.yaml diff --git a/README.md b/README.md index 900e91f..c123087 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,54 @@ -# CNCF End User Technical Advisory Board (TAB) - -## Overview +# End User Technical Advisory Board The CNCF End User Technical Advisory Board (TAB) acts as a vital voice of end users within the CNCF community. It plays a key role in advancing topics of concern to end users, enhancing visibility into end user adoption of CNCF projects, and raising awareness about the needs and perspectives of end users in the cloud native ecosystem. + + +## Leadership + +| Name | GitHub | Company | Role | +|------|--------|---------|------| +| [Ricardo Rocha](https://github.com/rochaporto) | @rochaporto | CERN | Chair & At-large Seat | +| [Joseph Sandoval](https://github.com/jrsapi) | @jrsapi | Adobe Inc | Vice Chair & Silver Member Seat | +| [Kenta Tada](https://github.com/KentaTada) | @KentaTada | Toyota | APAC Chair & Silver Member Seat | + + ## Members -### Platinum Member Seats +| Name | GitHub | Company | Role | +|------|--------|---------|------| +| [Alolita Sharma](https://github.com/alolita) | @alolita | Apple | Platinum Member Seat | +| [Michael Amundson](https://github.com/ma-cvs) | @ma-cvs | CVS Health | Platinum Member Seat | +| [Juliano Martinez](https://github.com/ncode) | @ncode | Adyen | Platinum Member Seat | +| [Xu Wang](https://github.com/gnawux) | @gnawux | Ant Group | Gold Member Seat | +| Ben Somogyi | @ | Lockheed Martin | Silver Member Seat | +| [Ahmed Bebars](https://github.com/abebars) | @abebars | The New York Times | At-large Seat | +| [Mike Bowen](https://github.com/michael-bowen-sc) | @michael-bowen-sc | Blackrock | At-large Seat | +| [Chad Beaudin](https://github.com/chadbeaudin) | @chadbeaudin | Boeing | TOC Appointed Seat | +| [Katie Gamanji](https://github.com/kgamanji) | @kgamanji | Apple | TOC Appointed Seat | -* [Alolita Sharma](https://github.com/alolita), Apple -* Juliano Martinez, Adyen -* Michael Amundson, CVS Health -### Gold Member Seats +## Emeritus -* [Xu Wang](https://github.com/gnawux), Ant Group +* [Amr Abdelhalem](https://github.com/ahalem) (Fidelity Investments) -### Silver Member Seats -* Ben Somogyi, Lockheed Martin -* **[Joseph Sandoval](https://github.com/jrsapi), Adobe Inc (Vice Chair)** -* [Kenta Tada](https://github.com/KentaTada), Toyota +## Contact +* Slack: [#tab](https://cloud-native.slack.com/archives/C05T9P1HXR6) +* Mailing List: [cncf-enduser@lists.cncf.io](mailto:cncf-enduser@lists.cncf.io) +* GitHub Teams: +* [@cncf/tab](https://github.com/orgs/cncf/teams/tab) — End User Technical Advisory Board members -### At-large Seats -* [Ahmed Bebars](https://github.com/abebars), The New York Times -* [Mike Bowen](https://github.com/michael-bowen-sc), Blackrock -* **[Ricardo Rocha](https://github.com/rochaporto), CERN (Chair)** +## Meetings -### TOC Appointed Seats +* TAB Public Meeting — 3rd Monday of every month at 8AM PT — [Join](https://zoom.us/j/96509520391) + * [Meeting Notes](https://github.com/orgs/cncf/projects/60) + + + -* [Chad Beaudin](https://github.com/chadbeaudin), Boeing -* [Katie Gamanji](https://github.com/kgamanji), Apple - ## Purpose and Duties The End User TAB is tasked with: @@ -81,44 +96,12 @@ The End User TAB may: * Advise the Governing Board and create SIGs or subcommittees for specialized topics. * Develop policies and procedures consistent with this charter. -## Meetings - -The TAB hosts a public meeting on the 3rd Monday of every month at 8AM PT (USA Pacific Time; [Convert to local time zone](http://www.thetimezoneconverter.com/?t=8:00AM&tz=San%20Francisco)). - -https://zoom.us/j/96509520391 Passcode: 407305 - -Here is a [Google calendar for all public CNCF events](https://goo.gl/eyutah). This calendar is also available on the [CNCF Website](https://www.cncf.io/calendar/). - -The meeting agenda and minutes are kept on [this github project](https://github.com/orgs/cncf/projects/60). - -## Communication - -The TAB and End User communities are available via multiple channels. -* End User mailing list: [cncf-enduser@lists.cncf.io](mailto:cncf-enduser@lists.cncf.io), [public mailing archive](https://lists.cncf.io/g/cncf-enduser) -* Slack at [https://cloud-native.slack.com/archives/C05T9P1HXR6](#tab) and [https://cloud-native.slack.com/archives/G95G3RZ25](#enduser): Join the [CNCF slack](https://slack.cncf.io/) - -If your organization is already a member of the [End User Community](https://www.cncf.io/enduser), you can request additional access via the [CNCF Member Desk](https://helpcenter.linuxfoundation.org/) or email [support@cncf.io](mailto:support@cncf.io). - -## Contribution - -Join our [public meetings](#meetings) or one of the End User Groups: -* Developer Experience User Group -* [Research User Group](https://github.com/cncf/research-user-group) -* [Public Sector User Group](https://github.com/cncf/public-sector-user-group) - ## Case Studies The CNCF produces and lists [case studies](https://www.cncf.io/case-studies) for our end users and projects. If you're interested in contributing a case study, please reference our [CNCF End User Stories Guidelines](https://github.com/cncf/foundation/blob/master/case-study-guidelines.md). -## Community Events - -### Cloud Native Executive Summit - -The Cloud Native Executive Summit is an exclusive event to hear from key decision makers and leaders within the cloud native and open source world with the goal of sharing best practices, use cases, and future planning for furthering CNCF’s mission of making cloud native ubiquitous. - -If you would like to participate in the Cloud Native Executive Summit, please reach out to [enduser-support@cncf.io](mailto:enduser-support@cncf.io) for more information. ## Join the End User Community @@ -147,3 +130,6 @@ The CNCF End User Community enables you to: - Work with other organizations in the [TODO Group](https://todogroup.org) to build an open source culture and open source program office within your organization. - Educate leadership and legal teams in open source via [Linux Foundation leadership and community events](https://events.linuxfoundation.org/about/calendar/?_sft_lfevent-category=leadership-community-events). + + + diff --git a/gov.yaml b/gov.yaml new file mode 100644 index 0000000..e3976a9 --- /dev/null +++ b/gov.yaml @@ -0,0 +1,76 @@ +tab: + - name: End User Technical Advisory Board + dir: "/" + mission_statement: > + The CNCF End User Technical Advisory Board (TAB) acts as a vital voice of + end users within the CNCF community. It plays a key role in advancing topics + of concern to end users, enhancing visibility into end user adoption of CNCF + projects, and raising awareness about the needs and perspectives of end users + in the cloud native ecosystem. + leadership: + - name: Ricardo Rocha + github: rochaporto + company: CERN + role: Chair & At-large Seat + - name: Joseph Sandoval + github: jrsapi + company: Adobe Inc + role: Vice Chair & Silver Member Seat + - name: Kenta Tada + github: KentaTada + company: Toyota + role: APAC Chair & Silver Member Seat + members: + - name: Alolita Sharma + github: alolita + company: Apple + role: Platinum Member Seat + - name: Michael Amundson + github: ma-cvs + company: CVS Health + role: Platinum Member Seat + - name: Juliano Martinez + github: ncode + company: Adyen + role: Platinum Member Seat + - name: Xu Wang + github: gnawux + company: Ant Group + role: Gold Member Seat + - name: Ben Somogyi + company: Lockheed Martin + role: Silver Member Seat + - name: Ahmed Bebars + github: abebars + company: The New York Times + role: At-large Seat + - name: Mike Bowen + github: michael-bowen-sc + company: Blackrock + role: At-large Seat + - name: Chad Beaudin + github: chadbeaudin + company: Boeing + role: TOC Appointed Seat + - name: Katie Gamanji + github: kgamanji + company: Apple + role: TOC Appointed Seat + emeritus: + - name: Amr Abdelhalem + github: ahalem + company: Fidelity Investments + role: Gold Member Representative + meetings: + - description: "TAB Public Meeting — 3rd Monday of every month at 8AM PT" + meeting_url: "https://zoom.us/j/96509520391" + meeting_notes_url: "https://github.com/orgs/cncf/projects/60" + contact: + slack: "https://cloud-native.slack.com/archives/C05T9P1HXR6" + slack_channel: tab + mailing_list: cncf-enduser@lists.cncf.io + github_teams: + - name: tab + description: End User Technical Advisory Board members + +user_groups: [] From 0c2b42611ab872f3f3badde635ef52d7b9a11456 Mon Sep 17 00:00:00 2001 From: Bob Killen Date: Wed, 2 Sep 2026 13:34:09 -0500 Subject: [PATCH 3/3] Add gov.yaml validate and gen PR actions Signed-off-by: Bob Killen --- .github/workflows/regenerate-governance.yaml | 35 ++++++++++++++++++++ .github/workflows/validate-gov.yaml | 27 +++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 .github/workflows/regenerate-governance.yaml create mode 100644 .github/workflows/validate-gov.yaml diff --git a/.github/workflows/regenerate-governance.yaml b/.github/workflows/regenerate-governance.yaml new file mode 100644 index 0000000..bd2be3a --- /dev/null +++ b/.github/workflows/regenerate-governance.yaml @@ -0,0 +1,35 @@ +name: Regenerate governance files + +on: + push: + paths: + - gov.yaml + +permissions: + contents: write + pull-requests: write + +jobs: + generate: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v6 + with: + go-version-file: generator/go.mod + + - name: Generate governance files + working-directory: generator + run: go run . + + - name: Create generated-files pull request + uses: peter-evans/create-pull-request@v7 + with: + branch: chore/regenerate-governance-files + commit-message: "chore: regenerate governance files" + title: "chore: regenerate governance files" + body: | + Regenerates governance README files from `gov.yaml`. + labels: automated-pr diff --git a/.github/workflows/validate-gov.yaml b/.github/workflows/validate-gov.yaml new file mode 100644 index 0000000..503c267 --- /dev/null +++ b/.github/workflows/validate-gov.yaml @@ -0,0 +1,27 @@ +name: Validate governance data + +on: + pull_request: + paths: + - gov.yaml + +permissions: + contents: read + +jobs: + generate: + name: Validate generated governance files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v6 + with: + go-version-file: generator/go.mod + + - name: Generate governance files + working-directory: generator + run: go run . + + - name: Verify generated files are committed + run: git diff --exit-code