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
31 changes: 31 additions & 0 deletions api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,38 @@ type Cron struct {
}

// GitHubReporting configures status reporting back to GitHub.
// All GitHub sources (issues, pull requests, webhooks) support comment
// reporting via the Enabled field. The Checks field is supported for
// githubPullRequests and for githubWebhook sources that include at least
// one pull-request event type; other sources reject it via CEL validation.
type GitHubReporting struct {
// Enabled posts standard status comments back to the originating GitHub issue or PR.
// +optional
Enabled bool `json:"enabled,omitempty"`

// Checks creates GitHub Check Runs for pull request tasks. When nil,
// no Check Runs are created. Supported for githubPullRequests and
// githubWebhook sources with pull-request event types.
// +optional
Checks *GitHubChecksReporting `json:"checks,omitempty"`
}

// GitHubChecksReporting configures GitHub Check Run reporting for pull
// request tasks, enabling branch protection and merge queue integration.
// When present, the spawner creates a Check Run when a task starts (status:
// in_progress) and updates it when the task completes (conclusion:
// success/failure). The check name appears in the Check Run title, while
// the task name appears in the summary.
// Requires the GitHub token to have checks:write permission.
type GitHubChecksReporting struct {
// Name overrides the default Check Run name ("Kelos: <taskspawner-name>").
// This name appears in branch protection rule configuration and the PR
// Checks tab. The default is stable across releases; note that renaming
// the TaskSpawner changes the default and may require updating any branch
// protection rules that reference it.
// +optional
// +kubebuilder:validation:MaxLength=100
Name string `json:"name,omitempty"`
}

// GitHubTeamRef identifies a GitHub team in org/team-slug format.
Expand Down Expand Up @@ -109,6 +137,7 @@ type GitHubCommentPolicy struct {
// fork but issues should be discovered from the upstream repository.
// If the workspace has a secretRef, it is used for GitHub API authentication.
// +kubebuilder:validation:XValidation:rule="!(has(self.commentPolicy) && ((has(self.triggerComment) && size(self.triggerComment) > 0) || (has(self.excludeComments) && size(self.excludeComments) > 0)))",message="commentPolicy cannot be used with triggerComment or excludeComments"
// +kubebuilder:validation:XValidation:rule="!has(self.reporting) || !has(self.reporting.checks)",message="checks reporting is not supported for githubIssues source"
type GitHubIssues struct {
// Repo optionally overrides the repository to poll for issues, in
// "owner/repo" format or as a full URL. When empty, the repository
Expand Down Expand Up @@ -346,11 +375,13 @@ type Jira struct {
}

// GitHubWebhook configures webhook-driven task spawning from GitHub events.
// +kubebuilder:validation:XValidation:rule="!has(self.reporting) || !has(self.reporting.checks) || self.events.exists(e, e in ['pull_request', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target'])",message="checks reporting requires at least one pull-request event type"
type GitHubWebhook struct {
// Events is the list of GitHub event types to listen for.
// e.g., "issue_comment", "pull_request_review", "push", "issues"
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=20
Events []string `json:"events"`

// Repository restricts webhooks to a specific repository (owner/repo format).
Expand Down
26 changes: 23 additions & 3 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 32 additions & 3 deletions cmd/kelos-spawner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,12 +492,23 @@ func sourceAnnotations(ts *kelosv1alpha1.TaskSpawner, item source.WorkItem) map[
annotations[reporting.AnnotationGitHubReporting] = "enabled"
}

if checksReportingEnabled(ts) {
annotations[reporting.AnnotationGitHubChecks] = "enabled"
if item.HeadSHA != "" {
annotations[reporting.AnnotationSourceSHA] = item.HeadSHA
}
if name := resolvedCheckName(ts); name != "" {
annotations[reporting.AnnotationGitHubCheckName] = name
}
}

return annotations
}

// reportingEnabled returns true when GitHub reporting is configured and enabled
// on the TaskSpawner. This only covers polling-based sources (Issues, PRs);
// webhook-based reporting is handled by the webhook server and its handler.
// reportingEnabled returns true when GitHub comment reporting is configured
// and enabled on the TaskSpawner. This only covers polling-based sources
// (Issues, PRs); webhook-based reporting is handled by the webhook server
// and its handler.
func reportingEnabled(ts *kelosv1alpha1.TaskSpawner) bool {
if ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Reporting != nil {
return ts.Spec.When.GitHubIssues.Reporting.Enabled
Expand All @@ -508,6 +519,24 @@ func reportingEnabled(ts *kelosv1alpha1.TaskSpawner) bool {
return false
}

// checksReportingEnabled returns true when GitHub Checks API reporting is
// configured and enabled on the TaskSpawner.
func checksReportingEnabled(ts *kelosv1alpha1.TaskSpawner) bool {
if ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.Reporting != nil && ts.Spec.When.GitHubPullRequests.Reporting.Checks != nil {
return true
}
return false
}

// resolvedCheckName returns the configured check name, or empty string for
// the default.
func resolvedCheckName(ts *kelosv1alpha1.TaskSpawner) string {
if ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.Reporting != nil && ts.Spec.When.GitHubPullRequests.Reporting.Checks != nil {
return ts.Spec.When.GitHubPullRequests.Reporting.Checks.Name
}
return ""
}

type resolvedGitHubCommentPolicy struct {
TriggerComment string
ExcludeComments []string
Expand Down
171 changes: 171 additions & 0 deletions cmd/kelos-spawner/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2124,6 +2124,177 @@ func TestReportingEnabled_Jira(t *testing.T) {
}
}

func TestChecksReportingEnabled_PREnabled(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{
Reporting: &kelosv1alpha1.GitHubReporting{Checks: &kelosv1alpha1.GitHubChecksReporting{}},
},
},
},
}
if !checksReportingEnabled(ts) {
t.Error("Expected checks reporting to be enabled")
}
}

func TestChecksReportingEnabled_PRDisabled(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{
Reporting: &kelosv1alpha1.GitHubReporting{Enabled: true},
},
},
},
}
if checksReportingEnabled(ts) {
t.Error("Expected checks reporting to be disabled when only comment reporting is enabled")
}
}

func TestChecksReportingEnabled_Issues(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubIssues: &kelosv1alpha1.GitHubIssues{
Reporting: &kelosv1alpha1.GitHubReporting{Enabled: true},
},
},
},
}
if checksReportingEnabled(ts) {
t.Error("Expected checks reporting to be disabled for issues source")
}
}

func TestChecksReportingEnabled_NoReportingField(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{},
},
},
}
if checksReportingEnabled(ts) {
t.Error("Expected checks reporting to be disabled when Reporting is nil")
}
}

func TestSourceAnnotations_ChecksEnabled(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{
Reporting: &kelosv1alpha1.GitHubReporting{
Checks: &kelosv1alpha1.GitHubChecksReporting{Name: "My Custom Check"},
},
},
},
},
}

item := source.WorkItem{
ID: "10",
Number: 10,
Kind: "PR",
HeadSHA: "deadbeef123",
}

annotations := sourceAnnotations(ts, item)
if annotations[reporting.AnnotationGitHubChecks] != "enabled" {
t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationGitHubChecks])
}
if annotations[reporting.AnnotationSourceSHA] != "deadbeef123" {
t.Errorf("Expected source-sha 'deadbeef123', got %q", annotations[reporting.AnnotationSourceSHA])
}
if annotations[reporting.AnnotationGitHubCheckName] != "My Custom Check" {
t.Errorf("Expected check name 'My Custom Check', got %q", annotations[reporting.AnnotationGitHubCheckName])
}
}

func TestSourceAnnotations_ChecksAndCommentsEnabled(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{
Reporting: &kelosv1alpha1.GitHubReporting{
Enabled: true,
Checks: &kelosv1alpha1.GitHubChecksReporting{},
},
},
},
},
}

item := source.WorkItem{
ID: "5",
Number: 5,
Kind: "PR",
HeadSHA: "abc123",
}

annotations := sourceAnnotations(ts, item)
if annotations[reporting.AnnotationGitHubReporting] != "enabled" {
t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationGitHubReporting])
}
if annotations[reporting.AnnotationGitHubChecks] != "enabled" {
t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationGitHubChecks])
}
}

func TestSourceAnnotations_ChecksNoSHA(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{
Reporting: &kelosv1alpha1.GitHubReporting{Checks: &kelosv1alpha1.GitHubChecksReporting{}},
},
},
},
}

item := source.WorkItem{
ID: "10",
Number: 10,
Kind: "PR",
// HeadSHA intentionally empty
}

annotations := sourceAnnotations(ts, item)
if annotations[reporting.AnnotationGitHubChecks] != "enabled" {
t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationGitHubChecks])
}
if _, ok := annotations[reporting.AnnotationSourceSHA]; ok {
t.Error("Expected no source-sha annotation when HeadSHA is empty")
}
}

func TestSourceAnnotations_ChecksNoCustomName(t *testing.T) {
ts := &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{
Reporting: &kelosv1alpha1.GitHubReporting{Checks: &kelosv1alpha1.GitHubChecksReporting{}},
},
},
},
}

item := source.WorkItem{
ID: "10",
Number: 10,
Kind: "PR",
HeadSHA: "sha",
}

annotations := sourceAnnotations(ts, item)
if _, ok := annotations[reporting.AnnotationGitHubCheckName]; ok {
t.Error("Expected no check-name annotation when CheckName is not configured")
}
}

func TestRunReportingCycle_ReportsForAnnotatedTasks(t *testing.T) {
ts := newTaskSpawner("spawner", "default", nil)
ts.Spec.When.GitHubIssues.Reporting = &kelosv1alpha1.GitHubReporting{Enabled: true}
Expand Down
Loading
Loading