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
7 changes: 6 additions & 1 deletion action.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
name: "PR Kind Labeler"
description: "Sync /kind commands in PR body to GitHub labels and enforce changelog notes"
description: "Sync /kind commands in PR body to GitHub labels, enforce changelog notes, and validate descriptions"
inputs:
token:
description: "GITHUB_TOKEN or a `repo` scoped Personal Access Token (PAT)"
default: ${{ github.token }}
required: false
enforce_description:
description: "Enforce that the Description section in the PR body is filled out"
default: "true"
required: false
runs:
using: "docker"
image: "Dockerfile"
args:
- ${{ inputs.token }}
- ${{ inputs.enforce_description }}
67 changes: 52 additions & 15 deletions internal/labeler/labeler.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,34 @@ var (
kindRE = regexp.MustCompile(`(?im)^/kind\s+([a-z0-9_/-]+)`)
// releaseNoteRE captures the first fenced code block with the word "release-note" in it.
releaseNoteRE = regexp.MustCompile("(?s)```release-note\\s*(.*?)\\s*```")
// descriptionRE captures content under the # Description heading until the next level-1 heading or end of string.
// Only stops at # followed by space (level-1), not ## or ### (level-2+)
descriptionRE = regexp.MustCompile(`(?sm)^#[ \t]*Description[ \t]*\n(.*?)(?:^#[ \t]|\z)`)
)

// labeler handles PR labeling operations.
type labeler struct {
client *github.Client
owner string
repo string
prNum int
labelsToAdd map[string]bool
labelsToRemove map[string]bool
currentMap map[string]bool
client *github.Client
owner string
repo string
prNum int
labelsToAdd map[string]bool
labelsToRemove map[string]bool
currentMap map[string]bool
enforceDescription bool
}

// New creates a new Labeler instance.
func New(client *github.Client, owner, repo string, prNum int) *labeler {
func New(client *github.Client, owner, repo string, prNum int, enforceDescription bool) *labeler {
return &labeler{
client: client,
owner: owner,
repo: repo,
prNum: prNum,
labelsToAdd: map[string]bool{},
labelsToRemove: map[string]bool{},
currentMap: map[string]bool{},
client: client,
owner: owner,
repo: repo,
prNum: prNum,
labelsToAdd: map[string]bool{},
labelsToRemove: map[string]bool{},
currentMap: map[string]bool{},
enforceDescription: enforceDescription,
}
}

Expand All @@ -54,6 +59,8 @@ func (l *labeler) ProcessPR(ctx context.Context, body string, syncLabels bool) e
if err := l.fetchLabels(ctx); err != nil {
return err
}
// normalize line endings to \n (GitHub returns \r\n)
body = strings.ReplaceAll(body, "\r\n", "\n")
// strip HTML comments to make the body easier to parse.
sanitizedBody := commentRE.ReplaceAllString(body, "")

Expand All @@ -64,6 +71,11 @@ func (l *labeler) ProcessPR(ctx context.Context, body string, syncLabels bool) e
if err := l.processReleaseNotes(sanitizedBody); err != nil {
errs = append(errs, err)
}
if l.enforceDescription {
if err := l.processDescription(sanitizedBody); err != nil {
errs = append(errs, err)
}
}
if syncLabels {
if err := l.syncLabels(ctx); err != nil {
errs = append(errs, err)
Expand Down Expand Up @@ -228,6 +240,31 @@ func (l *labeler) processReleaseNotes(body string) error {
return nil
}

// processDescription handles the description validation and labeling
func (l *labeler) processDescription(body string) error {
// validate the description block is present
match := descriptionRE.FindStringSubmatch(body)
if len(match) < 2 {
if !l.currentMap[labels.InvalidDescriptionLabel] {
l.labelsToAdd[labels.InvalidDescriptionLabel] = true
}
return fmt.Errorf("missing # Description section in PR body; please add a description explaining the changes")
}
// check if the description content is meaningful (not empty or just whitespace)
descriptionContent := strings.TrimSpace(match[1])
if descriptionContent == "" {
if !l.currentMap[labels.InvalidDescriptionLabel] {
l.labelsToAdd[labels.InvalidDescriptionLabel] = true
}
return fmt.Errorf("empty # Description section in PR body; please add a meaningful description explaining the changes")
}
// description is valid, remove the invalid label if present
if l.currentMap[labels.InvalidDescriptionLabel] {
l.labelsToRemove[labels.InvalidDescriptionLabel] = true
}
return nil
}

func (l *labeler) syncLabels(ctx context.Context) error {
var errs []error
labelsToAdd := make([]string, 0, len(l.labelsToAdd))
Expand Down
Loading