diff --git a/action.yaml b/action.yaml index 457903e..8fabe52 100644 --- a/action.yaml +++ b/action.yaml @@ -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 }} diff --git a/internal/labeler/labeler.go b/internal/labeler/labeler.go index 72ec85b..a2d08ed 100644 --- a/internal/labeler/labeler.go +++ b/internal/labeler/labeler.go @@ -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, } } @@ -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, "") @@ -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) @@ -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)) diff --git a/internal/labeler/labeler_test.go b/internal/labeler/labeler_test.go index 67b9f2e..aacfd48 100644 --- a/internal/labeler/labeler_test.go +++ b/internal/labeler/labeler_test.go @@ -59,7 +59,7 @@ func TestProcessPR_NoKindSupplied(t *testing.T) { ) c := github.NewClient(httpClient) - l := New(c, "foo", "bar", 42) + l := New(c, "foo", "bar", 42, false) err := l.ProcessPR(context.Background(), "```release-note\nOK\n```", true) if err == nil || !strings.Contains(err.Error(), "no /kind") { t.Fatalf("expected an error when no kind is supplied, got %v", err) @@ -112,7 +112,7 @@ func TestProcessPR_InvalidKind(t *testing.T) { ), ) c := github.NewClient(httpClient) - l := New(c, "foo", "bar", 42) + l := New(c, "foo", "bar", 42, false) err := l.ProcessPR(context.Background(), "/kind banana\n```release-note\nOK\n```", true) if err == nil || !strings.Contains(err.Error(), "invalid /kind") { t.Fatalf("expected kind-invalid error, got %v", err) @@ -168,7 +168,7 @@ func TestProcessPR_ValidKind_InvalidReleaseNote(t *testing.T) { }), ), ) - l := New(github.NewClient(httpClient), "foo", "bar", 45) + l := New(github.NewClient(httpClient), "foo", "bar", 45, false) err := l.ProcessPR(context.Background(), "/kind fix\n```release-note\n\n```", true) if err == nil || !strings.Contains(err.Error(), "missing or empty") { t.Fatalf("expected missing release-note error, got %v", err) @@ -224,7 +224,7 @@ func TestProcessPR_ValidKindAndReleaseNote(t *testing.T) { }), ), ) - l := New(github.NewClient(httpClient), "foo", "bar", 43) + l := New(github.NewClient(httpClient), "foo", "bar", 43, false) err := l.ProcessPR(context.Background(), "/kind feature\n```release-note\nNew feature implemented\n```", true) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -280,7 +280,7 @@ func TestProcessPR_MultipleKinds(t *testing.T) { }), ), ) - l := New(github.NewClient(httpClient), "foo", "bar", 44) + l := New(github.NewClient(httpClient), "foo", "bar", 44, false) err := l.ProcessPR(context.Background(), "/kind feature\n/kind cleanup\n```release-note\nCleanup and feature\n```", true) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -335,7 +335,7 @@ func TestProcessPR_ReleaseNoteNone(t *testing.T) { }), ), ) - l := New(github.NewClient(httpClient), "foo", "bar", 46) + l := New(github.NewClient(httpClient), "foo", "bar", 46, false) err := l.ProcessPR(context.Background(), "/kind cleanup\n```release-note\nNONE\n```", true) if err != nil { t.Fatalf("expected no error on NONE, got %v", err) @@ -399,7 +399,7 @@ func TestProcessPR_EditedToInvalid(t *testing.T) { ), ) - l := New(github.NewClient(httpClient), "foo", "bar", 47) + l := New(github.NewClient(httpClient), "foo", "bar", 47, false) err := l.ProcessPR(context.Background(), "/kind fix\nNo release-note here", true) if err == nil || !strings.Contains(err.Error(), "missing or empty ```release-note``` block") { t.Fatalf("ProcessPR error expected to contain 'missing or empty ```release-note``` block', got: %v", err.Error()) @@ -462,7 +462,7 @@ func TestProcessPR_EditedToValid(t *testing.T) { ), ) - l := New(github.NewClient(httpClient), "foo", "bar", 47) + l := New(github.NewClient(httpClient), "foo", "bar", 47, false) err := l.ProcessPR(context.Background(), "/kind fix\\n```release-note\\nFixed it\\n```", true) if err != nil { t.Fatalf("expected no error from ProcessPR, got %v", err) @@ -588,7 +588,7 @@ func TestProcessPR_LabelMigrationTableDriven(t *testing.T) { ), ) - l := New(github.NewClient(httpClient), "owner", "repo", tc.prNum) + l := New(github.NewClient(httpClient), "owner", "repo", tc.prNum, false) err := l.ProcessPR(context.Background(), tc.prBody, true) if err != nil { t.Fatalf("Expected no error, but got: %v", err) @@ -664,7 +664,7 @@ func TestProcessPR_RemovesKindInvalid_WhenValidKindProvided(t *testing.T) { ), ) - l := New(github.NewClient(httpClient), "owner", "repo", prNum) + l := New(github.NewClient(httpClient), "owner", "repo", prNum, false) err := l.ProcessPR(context.Background(), "/kind feature\\n```release-note\\nNONE\\n```", true) if err != nil { t.Fatalf("Expected no error, but got: %v", err) @@ -676,3 +676,361 @@ func TestProcessPR_RemovesKindInvalid_WhenValidKindProvided(t *testing.T) { t.Errorf("Expected labels to remove %v, got %v", expectedLabelsToRemove, actualLabelsRemoved) } } + +func TestProcessPR_MissingDescription(t *testing.T) { + expectedLabelsToAdd := []string{ + fmt.Sprintf("kind/%s", kinds.Fix), + labels.ReleaseNoteLabel, + labels.InvalidDescriptionLabel, + } + sort.Strings(expectedLabelsToAdd) + expectedLabelsToRemove := []string{} + + var actualLabelsAdded []string = make([]string, 0) + var actualLabelsRemoved []string = make([]string, 0) + + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesLabelsByOwnerByRepoByIssueNumber, + []*github.Label{}, + ), + mock.WithRequestMatchHandler( + mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&actualLabelsAdded); err != nil { + t.Fatalf("AddLabels Handler: failed to decode body: %v", err) + } + sort.Strings(actualLabelsAdded) + responseLabels := make([]*github.Label, len(actualLabelsAdded)) + for i, name := range actualLabelsAdded { + responseLabels[i] = &github.Label{Name: github.Ptr(name)} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responseLabels) + }), + ), + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + labelName := parts[len(parts)-1] + actualLabelsRemoved = append(actualLabelsRemoved, labelName) + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + c := github.NewClient(httpClient) + l := New(c, "foo", "bar", 50, true) + err := l.ProcessPR(context.Background(), "/kind fix\n```release-note\nFixed bug\n```", true) + if err == nil || !strings.Contains(err.Error(), "missing # Description section") { + t.Fatalf("expected missing Description error, got %v", err) + } + if !reflect.DeepEqual(actualLabelsAdded, expectedLabelsToAdd) { + t.Fatalf("Expected labels to be added %v, got %v", expectedLabelsToAdd, actualLabelsAdded) + } + sort.Strings(actualLabelsRemoved) + if !reflect.DeepEqual(actualLabelsRemoved, expectedLabelsToRemove) { + t.Fatalf("Expected labels to be removed %v, got %v", expectedLabelsToRemove, actualLabelsRemoved) + } +} + +func TestProcessPR_EmptyDescription(t *testing.T) { + expectedLabelsToAdd := []string{ + fmt.Sprintf("kind/%s", kinds.Fix), + labels.ReleaseNoteLabel, + labels.InvalidDescriptionLabel, + } + sort.Strings(expectedLabelsToAdd) + expectedLabelsToRemove := []string{} + + var actualLabelsAdded []string = make([]string, 0) + var actualLabelsRemoved []string = make([]string, 0) + + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesLabelsByOwnerByRepoByIssueNumber, + []*github.Label{}, + ), + mock.WithRequestMatchHandler( + mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&actualLabelsAdded); err != nil { + t.Fatalf("AddLabels Handler: failed to decode body: %v", err) + } + sort.Strings(actualLabelsAdded) + responseLabels := make([]*github.Label, len(actualLabelsAdded)) + for i, name := range actualLabelsAdded { + responseLabels[i] = &github.Label{Name: github.Ptr(name)} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responseLabels) + }), + ), + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + labelName := parts[len(parts)-1] + actualLabelsRemoved = append(actualLabelsRemoved, labelName) + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + c := github.NewClient(httpClient) + l := New(c, "foo", "bar", 51, true) + prBody := "# Description\n\n# Change Type\n/kind fix\n\n```release-note\nFixed bug\n```" + err := l.ProcessPR(context.Background(), prBody, true) + if err == nil || !strings.Contains(err.Error(), "empty # Description section") { + t.Fatalf("expected empty Description error, got %v", err) + } + if !reflect.DeepEqual(actualLabelsAdded, expectedLabelsToAdd) { + t.Fatalf("Expected labels to be added %v, got %v", expectedLabelsToAdd, actualLabelsAdded) + } + sort.Strings(actualLabelsRemoved) + if !reflect.DeepEqual(actualLabelsRemoved, expectedLabelsToRemove) { + t.Fatalf("Expected labels to be removed %v, got %v", expectedLabelsToRemove, actualLabelsRemoved) + } +} + +func TestProcessPR_ValidDescription(t *testing.T) { + expectedLabelsToAdd := []string{ + fmt.Sprintf("kind/%s", kinds.Fix), + labels.ReleaseNoteLabel, + } + sort.Strings(expectedLabelsToAdd) + expectedLabelsToRemove := []string{} + + var actualLabelsAdded []string = make([]string, 0) + var actualLabelsRemoved []string = make([]string, 0) + + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesLabelsByOwnerByRepoByIssueNumber, + []*github.Label{}, + ), + mock.WithRequestMatchHandler( + mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&actualLabelsAdded); err != nil { + t.Fatalf("AddLabels Handler: failed to decode body: %v", err) + } + sort.Strings(actualLabelsAdded) + responseLabels := make([]*github.Label, len(actualLabelsAdded)) + for i, name := range actualLabelsAdded { + responseLabels[i] = &github.Label{Name: github.Ptr(name)} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responseLabels) + }), + ), + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + labelName := parts[len(parts)-1] + actualLabelsRemoved = append(actualLabelsRemoved, labelName) + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + c := github.NewClient(httpClient) + l := New(c, "foo", "bar", 52, true) + prBody := "# Description\n\nThis PR fixes a critical bug in the authentication flow.\n\n# Change Type\n/kind fix\n\n```release-note\nFixed authentication bug\n```" + err := l.ProcessPR(context.Background(), prBody, true) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !reflect.DeepEqual(actualLabelsAdded, expectedLabelsToAdd) { + t.Fatalf("Expected labels to be added %v, got %v", expectedLabelsToAdd, actualLabelsAdded) + } + sort.Strings(actualLabelsRemoved) + if !reflect.DeepEqual(actualLabelsRemoved, expectedLabelsToRemove) { + t.Fatalf("Expected labels to be removed %v, got %v", expectedLabelsToRemove, actualLabelsRemoved) + } +} + +func TestProcessPR_DescriptionValidationDisabled(t *testing.T) { + expectedLabelsToAdd := []string{ + fmt.Sprintf("kind/%s", kinds.Fix), + labels.ReleaseNoteLabel, + } + sort.Strings(expectedLabelsToAdd) + expectedLabelsToRemove := []string{} + + var actualLabelsAdded []string = make([]string, 0) + var actualLabelsRemoved []string = make([]string, 0) + + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesLabelsByOwnerByRepoByIssueNumber, + []*github.Label{}, + ), + mock.WithRequestMatchHandler( + mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&actualLabelsAdded); err != nil { + t.Fatalf("AddLabels Handler: failed to decode body: %v", err) + } + sort.Strings(actualLabelsAdded) + responseLabels := make([]*github.Label, len(actualLabelsAdded)) + for i, name := range actualLabelsAdded { + responseLabels[i] = &github.Label{Name: github.Ptr(name)} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responseLabels) + }), + ), + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + labelName := parts[len(parts)-1] + actualLabelsRemoved = append(actualLabelsRemoved, labelName) + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + c := github.NewClient(httpClient) + l := New(c, "foo", "bar", 53, false) + // No description section, but validation is disabled + err := l.ProcessPR(context.Background(), "/kind fix\n```release-note\nFixed bug\n```", true) + if err != nil { + t.Fatalf("expected no error when description validation disabled, got %v", err) + } + if !reflect.DeepEqual(actualLabelsAdded, expectedLabelsToAdd) { + t.Fatalf("Expected labels to be added %v, got %v", expectedLabelsToAdd, actualLabelsAdded) + } + sort.Strings(actualLabelsRemoved) + if !reflect.DeepEqual(actualLabelsRemoved, expectedLabelsToRemove) { + t.Fatalf("Expected labels to be removed %v, got %v", expectedLabelsToRemove, actualLabelsRemoved) + } +} + +func TestProcessPR_RemovesInvalidDescription_WhenValidDescriptionProvided(t *testing.T) { + expectedLabelsToAdd := []string{ + fmt.Sprintf("kind/%s", kinds.Fix), + labels.ReleaseNoteLabel, + } + sort.Strings(expectedLabelsToAdd) + expectedLabelsToRemove := []string{ + labels.InvalidDescriptionLabel, + } + sort.Strings(expectedLabelsToRemove) + + var actualLabelsAdded []string = make([]string, 0) + var actualLabelsRemoved []string = make([]string, 0) + + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesLabelsByOwnerByRepoByIssueNumber, + []*github.Label{ + {Name: github.Ptr(labels.InvalidDescriptionLabel)}, + }, + ), + mock.WithRequestMatchHandler( + mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&actualLabelsAdded); err != nil { + t.Fatalf("AddLabels Handler: failed to decode body: %v", err) + } + sort.Strings(actualLabelsAdded) + responseLabels := make([]*github.Label, len(actualLabelsAdded)) + for i, name := range actualLabelsAdded { + responseLabels[i] = &github.Label{Name: github.Ptr(name)} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responseLabels) + }), + ), + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pathPrefix := fmt.Sprintf("/repos/%s/%s/issues/%d/labels/", "foo", "bar", 54) + labelNameSegment := strings.TrimPrefix(r.URL.Path, pathPrefix) + decodedLabelName, err := url.PathUnescape(labelNameSegment) + if err != nil { + t.Fatalf("Failed to unescape label name segment '%s': %v", labelNameSegment, err) + } + actualLabelsRemoved = append(actualLabelsRemoved, decodedLabelName) + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + c := github.NewClient(httpClient) + l := New(c, "foo", "bar", 54, true) + prBody := "# Description\n\nThis PR fixes an important bug.\n\n# Change Type\n/kind fix\n\n```release-note\nFixed important bug\n```" + err := l.ProcessPR(context.Background(), prBody, true) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !reflect.DeepEqual(actualLabelsAdded, expectedLabelsToAdd) { + t.Fatalf("Expected labels to be added %v, got %v", expectedLabelsToAdd, actualLabelsAdded) + } + sort.Strings(actualLabelsRemoved) + if !reflect.DeepEqual(actualLabelsRemoved, expectedLabelsToRemove) { + t.Fatalf("Expected labels to be removed %v, got %v", expectedLabelsToRemove, actualLabelsRemoved) + } +} + +func TestProcessPR_ValidDescriptionWithSubheadings(t *testing.T) { + expectedLabelsToAdd := []string{ + fmt.Sprintf("kind/%s", kinds.Fix), + labels.ReleaseNoteLabel, + } + sort.Strings(expectedLabelsToAdd) + expectedLabelsToRemove := []string{} + + var actualLabelsAdded []string = make([]string, 0) + var actualLabelsRemoved []string = make([]string, 0) + + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesLabelsByOwnerByRepoByIssueNumber, + []*github.Label{}, + ), + mock.WithRequestMatchHandler( + mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&actualLabelsAdded); err != nil { + t.Fatalf("AddLabels Handler: failed to decode body: %v", err) + } + sort.Strings(actualLabelsAdded) + responseLabels := make([]*github.Label, len(actualLabelsAdded)) + for i, name := range actualLabelsAdded { + responseLabels[i] = &github.Label{Name: github.Ptr(name)} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responseLabels) + }), + ), + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + labelName := parts[len(parts)-1] + actualLabelsRemoved = append(actualLabelsRemoved, labelName) + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + c := github.NewClient(httpClient) + l := New(c, "foo", "bar", 55, true) + prBody := "# Description\n\n## Motivation\n\nThis fixes a bug.\n\n## Implementation\n\nUsed a different approach.\n\n# Change Type\n/kind fix\n\n```release-note\nFixed bug\n```" + err := l.ProcessPR(context.Background(), prBody, true) + if err != nil { + t.Fatalf("expected no error with subheadings in description, got %v", err) + } + if !reflect.DeepEqual(actualLabelsAdded, expectedLabelsToAdd) { + t.Fatalf("Expected labels to be added %v, got %v", expectedLabelsToAdd, actualLabelsAdded) + } + sort.Strings(actualLabelsRemoved) + if !reflect.DeepEqual(actualLabelsRemoved, expectedLabelsToRemove) { + t.Fatalf("Expected labels to be removed %v, got %v", expectedLabelsToRemove, actualLabelsRemoved) + } +} diff --git a/main.go b/main.go index 1bf0c57..e9cc8ca 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ func main() { cmd := cobra.Command{ Use: "pr-kind-labeler", Short: "Sync /kind commands in PR body to GitHub labels and enforce changelog notes", - Args: cobra.ExactArgs(1), + Args: cobra.RangeArgs(1, 2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -29,6 +29,15 @@ func main() { } client := github.NewClient(nil).WithAuthToken(token) + // parse enforce_description flag (defaults to true) + enforceDescription := true + if len(os.Args) > 2 { + enforceDescriptionStr := os.Args[2] + if enforceDescriptionStr == "false" { + enforceDescription = false + } + } + if ghprEnv := os.Getenv("GHPR"); ghprEnv != "" { // You can manually test, like so: // GHPR=kgateway-dev/kgateway/11221 go run . $GITHUB_API_TOKEN @@ -43,7 +52,7 @@ func main() { if err != nil { return fmt.Errorf("invalid PR number: %w", err) } - return manualTest(ctx, client, owner, repo, prNumInt) + return manualTest(ctx, client, owner, repo, prNumInt, enforceDescription) } eventPath := os.Getenv("GITHUB_EVENT_PATH") @@ -61,7 +70,7 @@ func main() { prNum := prEvent.GetNumber() body := prEvent.GetPullRequest().GetBody() - l := labeler.New(client, owner, repo, prNum) + l := labeler.New(client, owner, repo, prNum, enforceDescription) if err := l.ProcessPR(ctx, body, true); err != nil { return err } @@ -74,7 +83,7 @@ func main() { } } -func manualTest(ctx context.Context, client *github.Client, owner, repo string, prNum int) error { +func manualTest(ctx context.Context, client *github.Client, owner, repo string, prNum int, enforceDescription bool) error { prResp, _, err := client.PullRequests.Get(ctx, owner, repo, prNum) if err != nil { @@ -82,6 +91,6 @@ func manualTest(ctx context.Context, client *github.Client, owner, repo string, } body := prResp.GetBody() - l := labeler.New(client, owner, repo, prNum) + l := labeler.New(client, owner, repo, prNum, enforceDescription) return l.ProcessPR(ctx, body, false) } diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 0d243cf..24f2460 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -5,6 +5,8 @@ const ( InvalidKindLabel = "do-not-merge/kind-invalid" // InvalidReleaseNoteLabel is a label that indicates the release note is invalid. InvalidReleaseNoteLabel = "do-not-merge/release-note-invalid" + // InvalidDescriptionLabel is a label that indicates the description is invalid or missing. + InvalidDescriptionLabel = "do-not-merge/description-invalid" // ReleaseNoteLabel is a label that indicates the release note is needed. ReleaseNoteLabel = "release-note" // DeprecatedReleaseNoteLabel is a deprecated label that indicates the release note is needed.