Skip to content

Commit 4bb671f

Browse files
authored
Merge pull request #8596 from cblecker/election-bio-tool
Add Go-based validation utility for steering committee election bios
2 parents ff39998 + fad9166 commit 4bb671f

File tree

8 files changed

+714
-28
lines changed

8 files changed

+714
-28
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
-------------------------------------------------------------
22
name: Christoph Blecker
33
ID: cblecker
4+
info:
5+
employer: Red Hat
6+
slack: cblecker
47
-------------------------------------------------------------
58

69
# Christoph Blecker
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
-------------------------------------------------------------
22
name: Carlos Tadeu Panato Jr
33
ID: cpanato
4+
info:
5+
employer: Chainguard
6+
slack: cpanato
47
-------------------------------------------------------------
58

69
# Carlos Tadeu Panato Jr
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
-------------------------------------------------------------
22
name: Davanum Srinivas
33
ID: dims
4+
info:
5+
employer: Amazon Web Services
6+
slack: dims
47
-------------------------------------------------------------
58

69
# Davanum Srinivas
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
-------------------------------------------------------------
22
name: Nikhita Raghunath
33
ID: nikhita
4+
info:
5+
employer: VMware
6+
slack: nikhita
47
-------------------------------------------------------------
58

69
# Nikhita Raghunath

elections/steering/2025/nomination-template.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
name:
33
ID: GitHubID
44
info:
5-
- employer: Your Employer or "Independent"
6-
- slack: slack handle
5+
employer: Your Employer or "Independent"
6+
slack: slack handle
77
-------------------------------------------------------------
88

99
<!-- Please make a copy of this template as "candidate-githubid.md" and save it to
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
"regexp"
24+
"strings"
25+
"time"
26+
27+
yaml "gopkg.in/yaml.v3"
28+
)
29+
30+
const (
31+
maxWordCount = 450
32+
recommendedWordCount = 300
33+
startYear = 2026
34+
)
35+
36+
type ValidationError struct {
37+
File string
38+
Message string
39+
}
40+
41+
type CandidateHeader struct {
42+
Name string `yaml:"name"`
43+
ID string `yaml:"ID"`
44+
Info InfoData `yaml:"info"`
45+
}
46+
47+
type InfoData struct {
48+
Employer string `yaml:"employer"`
49+
Slack string `yaml:"slack"`
50+
}
51+
52+
53+
func main() {
54+
if len(os.Args) < 2 {
55+
fmt.Fprintf(os.Stderr, "Usage: %s <elections-path>\n", os.Args[0])
56+
os.Exit(1)
57+
}
58+
59+
electionsPath := os.Args[1]
60+
61+
bioFiles, err := findBioFiles(electionsPath)
62+
if err != nil {
63+
fmt.Fprintf(os.Stderr, "Error finding bio files: %v\n", err)
64+
os.Exit(1)
65+
}
66+
67+
var errors []ValidationError
68+
69+
for _, bioFile := range bioFiles {
70+
// Check word count
71+
wordCount, err := countWords(bioFile)
72+
if err != nil {
73+
fmt.Fprintf(os.Stderr, "Error counting words in %s: %v\n", bioFile, err)
74+
continue
75+
}
76+
77+
if wordCount > maxWordCount {
78+
errors = append(errors, ValidationError{
79+
File: bioFile,
80+
Message: fmt.Sprintf("has %d words", wordCount),
81+
})
82+
}
83+
84+
// Check filename format and GitHub ID matching
85+
if err := validateFileNameAndGitHubID(bioFile); err != nil {
86+
errors = append(errors, ValidationError{
87+
File: bioFile,
88+
Message: err.Error(),
89+
})
90+
}
91+
92+
// Check template compliance
93+
if err := validateTemplateCompliance(bioFile); err != nil {
94+
errors = append(errors, ValidationError{
95+
File: bioFile,
96+
Message: err.Error(),
97+
})
98+
}
99+
}
100+
101+
if len(errors) > 0 {
102+
for _, err := range errors {
103+
fmt.Printf("%s: %s\n", err.File, err.Message)
104+
}
105+
106+
separator := strings.Repeat("=", 68)
107+
fmt.Printf("\n%s\n", separator)
108+
fmt.Printf("%d invalid Steering Committee election bio(s) detected.\n", len(errors))
109+
fmt.Printf("Bios should be limited to around %d words, excluding headers.\n", recommendedWordCount)
110+
fmt.Printf("Bios must follow the nomination template and filename format.\n")
111+
fmt.Printf("%s\n", separator)
112+
os.Exit(1)
113+
}
114+
}
115+
116+
// findBioFiles finds all steering committee candidate bio files from 2025 forward
117+
func findBioFiles(electionsPath string) ([]string, error) {
118+
var bioFiles []string
119+
120+
// Walk through elections/steering directory specifically
121+
steeringPath := filepath.Join(electionsPath, "steering")
122+
err := filepath.Walk(steeringPath, func(path string, info os.FileInfo, err error) error {
123+
if err != nil {
124+
return err
125+
}
126+
127+
// Skip if not a regular file
128+
if !info.Mode().IsRegular() {
129+
return nil
130+
}
131+
132+
// Check if it's a candidate bio file
133+
if !strings.HasPrefix(info.Name(), "candidate-") || !strings.HasSuffix(info.Name(), ".md") {
134+
return nil
135+
}
136+
137+
// Only include elections from startYear forward
138+
pathLower := strings.ToLower(path)
139+
includeFile := false
140+
currentYear := time.Now().Year()
141+
// Check from startYear to a few years ahead of current year
142+
endYear := currentYear + 5
143+
for year := startYear; year <= endYear; year++ {
144+
yearStr := fmt.Sprintf("/%d/", year)
145+
if strings.Contains(pathLower, yearStr) {
146+
includeFile = true
147+
break
148+
}
149+
}
150+
151+
if !includeFile {
152+
return nil
153+
}
154+
155+
bioFiles = append(bioFiles, path)
156+
return nil
157+
})
158+
159+
return bioFiles, err
160+
}
161+
162+
// countWords counts the number of words in a file
163+
func countWords(filename string) (int, error) {
164+
content, err := os.ReadFile(filename)
165+
if err != nil {
166+
return 0, err
167+
}
168+
169+
// Split by whitespace and count non-empty strings
170+
words := regexp.MustCompile(`\s+`).Split(string(content), -1)
171+
count := 0
172+
for _, word := range words {
173+
if strings.TrimSpace(word) != "" {
174+
count++
175+
}
176+
}
177+
178+
return count, nil
179+
}
180+
181+
// validateFileNameAndGitHubID checks if filename matches format candidate-$username.md
182+
// and if the username matches the GitHub ID in the document header
183+
func validateFileNameAndGitHubID(filename string) error {
184+
// Extract filename from path
185+
base := filepath.Base(filename)
186+
187+
// Check filename format: candidate-*.md
188+
candidateRegex := regexp.MustCompile(`^candidate-([a-zA-Z0-9_-]+)\.md$`)
189+
matches := candidateRegex.FindStringSubmatch(base)
190+
if len(matches) != 2 {
191+
return fmt.Errorf("filename must follow format 'candidate-username.md'")
192+
}
193+
194+
expectedUsername := matches[1]
195+
196+
// Read file content to extract GitHub ID
197+
content, err := os.ReadFile(filename)
198+
if err != nil {
199+
return fmt.Errorf("error reading file: %v", err)
200+
}
201+
202+
// Extract YAML header and parse it
203+
yamlHeader, err := extractYAMLHeader(string(content))
204+
if err != nil {
205+
return fmt.Errorf("error extracting YAML header: %v", err)
206+
}
207+
208+
// Parse the YAML to get the ID field
209+
var header map[string]interface{}
210+
if err := yaml.Unmarshal([]byte(yamlHeader), &header); err != nil {
211+
return fmt.Errorf("error parsing YAML header: %v", err)
212+
}
213+
214+
idValue, exists := header["ID"]
215+
if !exists {
216+
return fmt.Errorf("missing 'ID' field in header")
217+
}
218+
219+
actualUsername, ok := idValue.(string)
220+
if !ok {
221+
return fmt.Errorf("'ID' field must be a string")
222+
}
223+
224+
actualUsername = strings.TrimSpace(actualUsername)
225+
if actualUsername != expectedUsername {
226+
return fmt.Errorf("filename username '%s' does not match GitHub ID '%s' in header", expectedUsername, actualUsername)
227+
}
228+
229+
return nil
230+
}
231+
232+
// validateTemplateCompliance checks if the bio follows the required template structure
233+
func validateTemplateCompliance(filename string) error {
234+
content, err := os.ReadFile(filename)
235+
if err != nil {
236+
return fmt.Errorf("error reading file: %v", err)
237+
}
238+
239+
contentStr := string(content)
240+
241+
// Extract YAML header between dashes
242+
yamlHeader, err := extractYAMLHeader(contentStr)
243+
if err != nil {
244+
return fmt.Errorf("error extracting YAML header: %v", err)
245+
}
246+
247+
// Parse YAML header
248+
var header CandidateHeader
249+
if err := yaml.Unmarshal([]byte(yamlHeader), &header); err != nil {
250+
return fmt.Errorf("invalid YAML header format: %v", err)
251+
}
252+
253+
// Validate required fields
254+
if header.Name == "" {
255+
return fmt.Errorf("missing required field: name")
256+
}
257+
if header.ID == "" {
258+
return fmt.Errorf("missing required field: ID")
259+
}
260+
if header.Info.Employer == "" {
261+
return fmt.Errorf("missing required field: info.employer")
262+
}
263+
if header.Info.Slack == "" {
264+
return fmt.Errorf("missing required field: info.slack")
265+
}
266+
267+
// Check for required sections
268+
requiredSections := map[string][]string{
269+
"SIGs": {"## SIGS", "## SIGs"},
270+
"What I have done": {"## What I have done"},
271+
"What I'll do": {"## What I'll do"},
272+
"Resources About Me": {"## Resources About Me"},
273+
}
274+
275+
for sectionName, alternatives := range requiredSections {
276+
found := false
277+
for _, section := range alternatives {
278+
if strings.Contains(contentStr, section) {
279+
found = true
280+
break
281+
}
282+
}
283+
if !found {
284+
return fmt.Errorf("missing required section: %s", sectionName)
285+
}
286+
}
287+
288+
return nil
289+
}
290+
291+
// extractYAMLHeader extracts the YAML content between dash separators
292+
// The format requires exactly 61 dashes for consistency
293+
func extractYAMLHeader(content string) (string, error) {
294+
// Find the YAML header between exactly 61 dashes
295+
dashRegex := regexp.MustCompile(`(?s)^-{61}\s*\n(.*?)\n-{61}\s*\n`)
296+
matches := dashRegex.FindStringSubmatch(content)
297+
if len(matches) != 2 {
298+
return "", fmt.Errorf("could not find YAML header between dashes")
299+
}
300+
return matches[1], nil
301+
}

0 commit comments

Comments
 (0)