Skip to content

Commit f15977b

Browse files
authored
Enforce Description section in PR body with optional flag (#19)
Signed-off-by: timflannagan <timflannagan@gmail.com>
1 parent effc15a commit f15977b

5 files changed

Lines changed: 442 additions & 31 deletions

File tree

action.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
name: "PR Kind Labeler"
2-
description: "Sync /kind commands in PR body to GitHub labels and enforce changelog notes"
2+
description: "Sync /kind commands in PR body to GitHub labels, enforce changelog notes, and validate descriptions"
33
inputs:
44
token:
55
description: "GITHUB_TOKEN or a `repo` scoped Personal Access Token (PAT)"
66
default: ${{ github.token }}
77
required: false
8+
enforce_description:
9+
description: "Enforce that the Description section in the PR body is filled out"
10+
default: "true"
11+
required: false
812
runs:
913
using: "docker"
1014
image: "Dockerfile"
1115
args:
1216
- ${{ inputs.token }}
17+
- ${{ inputs.enforce_description }}

internal/labeler/labeler.go

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,34 @@ var (
2222
kindRE = regexp.MustCompile(`(?im)^/kind\s+([a-z0-9_/-]+)`)
2323
// releaseNoteRE captures the first fenced code block with the word "release-note" in it.
2424
releaseNoteRE = regexp.MustCompile("(?s)```release-note\\s*(.*?)\\s*```")
25+
// descriptionRE captures content under the # Description heading until the next level-1 heading or end of string.
26+
// Only stops at # followed by space (level-1), not ## or ### (level-2+)
27+
descriptionRE = regexp.MustCompile(`(?sm)^#[ \t]*Description[ \t]*\n(.*?)(?:^#[ \t]|\z)`)
2528
)
2629

2730
// labeler handles PR labeling operations.
2831
type labeler struct {
29-
client *github.Client
30-
owner string
31-
repo string
32-
prNum int
33-
labelsToAdd map[string]bool
34-
labelsToRemove map[string]bool
35-
currentMap map[string]bool
32+
client *github.Client
33+
owner string
34+
repo string
35+
prNum int
36+
labelsToAdd map[string]bool
37+
labelsToRemove map[string]bool
38+
currentMap map[string]bool
39+
enforceDescription bool
3640
}
3741

3842
// New creates a new Labeler instance.
39-
func New(client *github.Client, owner, repo string, prNum int) *labeler {
43+
func New(client *github.Client, owner, repo string, prNum int, enforceDescription bool) *labeler {
4044
return &labeler{
41-
client: client,
42-
owner: owner,
43-
repo: repo,
44-
prNum: prNum,
45-
labelsToAdd: map[string]bool{},
46-
labelsToRemove: map[string]bool{},
47-
currentMap: map[string]bool{},
45+
client: client,
46+
owner: owner,
47+
repo: repo,
48+
prNum: prNum,
49+
labelsToAdd: map[string]bool{},
50+
labelsToRemove: map[string]bool{},
51+
currentMap: map[string]bool{},
52+
enforceDescription: enforceDescription,
4853
}
4954
}
5055

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

@@ -64,6 +71,11 @@ func (l *labeler) ProcessPR(ctx context.Context, body string, syncLabels bool) e
6471
if err := l.processReleaseNotes(sanitizedBody); err != nil {
6572
errs = append(errs, err)
6673
}
74+
if l.enforceDescription {
75+
if err := l.processDescription(sanitizedBody); err != nil {
76+
errs = append(errs, err)
77+
}
78+
}
6779
if syncLabels {
6880
if err := l.syncLabels(ctx); err != nil {
6981
errs = append(errs, err)
@@ -228,6 +240,31 @@ func (l *labeler) processReleaseNotes(body string) error {
228240
return nil
229241
}
230242

243+
// processDescription handles the description validation and labeling
244+
func (l *labeler) processDescription(body string) error {
245+
// validate the description block is present
246+
match := descriptionRE.FindStringSubmatch(body)
247+
if len(match) < 2 {
248+
if !l.currentMap[labels.InvalidDescriptionLabel] {
249+
l.labelsToAdd[labels.InvalidDescriptionLabel] = true
250+
}
251+
return fmt.Errorf("missing # Description section in PR body; please add a description explaining the changes")
252+
}
253+
// check if the description content is meaningful (not empty or just whitespace)
254+
descriptionContent := strings.TrimSpace(match[1])
255+
if descriptionContent == "" {
256+
if !l.currentMap[labels.InvalidDescriptionLabel] {
257+
l.labelsToAdd[labels.InvalidDescriptionLabel] = true
258+
}
259+
return fmt.Errorf("empty # Description section in PR body; please add a meaningful description explaining the changes")
260+
}
261+
// description is valid, remove the invalid label if present
262+
if l.currentMap[labels.InvalidDescriptionLabel] {
263+
l.labelsToRemove[labels.InvalidDescriptionLabel] = true
264+
}
265+
return nil
266+
}
267+
231268
func (l *labeler) syncLabels(ctx context.Context) error {
232269
var errs []error
233270
labelsToAdd := make([]string, 0, len(l.labelsToAdd))

0 commit comments

Comments
 (0)