From c49b8efa4d58eece330ed387dc05338e9afc308e Mon Sep 17 00:00:00 2001 From: Jan Soukup Date: Wed, 2 Sep 2026 17:05:14 +0200 Subject: [PATCH 1/7] Add GitLab source and webhook support for TaskSpawners Add GitLab as a first-class TaskSpawner source: spec.when.gitlab polls a project for issues and merge requests with label, state, comment-command, pipelineStatus and reviewState gates and reports Task status as notes; spec.when.gitlabWebhook spawns Tasks from GitLab webhooks via a new kelos-webhook-gitlab server or a WebhookGateway with spec.gitlab. Introduce Workspace.spec.provider (github|gitlab) so the secret key, agent credentials, git username and preconfigured CLI follow the git host. GitLab workspaces require a GITLAB_TOKEN key and get glab installed and configured in all agent images. GitLab fields live in v1alpha2 only and are preserved across v1alpha1 round-trips. Co-Authored-By: Claude Fable 5.1 --- api/v1alpha2/taskspawner_types.go | 228 ++++++++- api/v1alpha2/webhookgateway_types.go | 33 +- api/v1alpha2/workspace_types.go | 21 +- api/v1alpha2/zz_generated.deepcopy.go | 218 +++++++++ claude-code/Dockerfile | 12 + cmd/kelos-spawner/main.go | 106 +++- cmd/kelos-spawner/main_test.go | 203 ++++++++ cmd/kelos-spawner/reconciler.go | 25 +- cmd/kelos-webhook-server/main.go | 22 +- cmd/kelos-webhook-server/reporting.go | 76 ++- cmd/kelos-webhook-server/reporting_test.go | 139 ++++++ codex/Dockerfile | 12 + cursor/Dockerfile | 12 + docs/agent-image-interface.md | 24 +- docs/integration.md | 64 +++ docs/reference.md | 87 +++- examples/19-taskspawner-gitlab/README.md | 116 +++++ .../credentials-secret.yaml | 8 + .../gitlab-token-secret.yaml | 9 + .../gitlab-webhook-secret.yaml | 11 + .../taskspawner-ci-remediation.yaml | 40 ++ .../taskspawner-webhook.yaml | 56 +++ .../19-taskspawner-gitlab/taskspawner.yaml | 52 ++ examples/19-taskspawner-gitlab/workspace.yaml | 14 + examples/README.md | 1 + examples/helm-values-webhook.yaml | 7 + gemini/Dockerfile | 12 + hack/agent-glab-wrapper.sh | 51 ++ internal/cli/printer.go | 31 ++ internal/cli/printer_test.go | 64 +++ internal/controller/job_builder.go | 129 ++--- internal/controller/job_builder_test.go | 89 +++- internal/controller/session_controller.go | 9 +- .../controller/session_controller_test.go | 6 +- internal/controller/task_controller.go | 30 ++ internal/controller/taskspawner_controller.go | 64 ++- .../controller/taskspawner_controller_test.go | 175 +++++++ .../taskspawner_deployment_builder.go | 102 ++-- .../taskspawner_deployment_builder_test.go | 90 ++++ .../controller/webhookgateway_controller.go | 19 +- .../webhookgateway_controller_test.go | 58 +++ internal/controller/workerpool_controller.go | 94 +--- internal/controller/workspace_provider.go | 223 +++++++++ .../controller/workspace_provider_test.go | 190 ++++++++ internal/conversion/taskspawner.go | 56 +++ internal/conversion/taskspawner_test.go | 87 ++++ internal/conversion/workspace.go | 32 +- internal/conversion/workspace_test.go | 72 +++ .../kelos-crds/templates/taskspawner-crd.yaml | 294 +++++++++++- .../templates/webhookgateway-crd.yaml | 46 +- .../kelos-crds/templates/workspace-crd.yaml | 20 +- .../charts/kelos/templates/rbac.yaml | 2 +- .../kelos/templates/serviceaccount.yaml | 2 +- .../kelos/templates/webhook-gateway.yaml | 17 +- .../kelos/templates/webhook-ingress.yaml | 12 +- .../kelos/templates/webhook-server.yaml | 112 ++++- internal/manifests/charts/kelos/values.yaml | 23 +- internal/manifests/install-crd.yaml | 360 +++++++++++++- internal/reporting/github.go | 10 +- internal/reporting/github_test.go | 18 +- internal/reporting/gitlab.go | 146 ++++++ internal/reporting/gitlab_test.go | 142 ++++++ internal/reporting/watcher.go | 55 ++- internal/source/gitlab.go | 454 ++++++++++++++++++ internal/source/gitlab_comment_policy.go | 172 +++++++ internal/source/gitlab_comment_policy_test.go | 176 +++++++ internal/source/gitlab_test.go | 379 +++++++++++++++ internal/source/prompt.go | 9 +- internal/source/source.go | 10 +- internal/telemetry/telemetry.go | 7 + internal/webhook/gateway_handler.go | 23 +- internal/webhook/gateway_handler_test.go | 76 +++ internal/webhook/gitlab_filter.go | 383 +++++++++++++++ internal/webhook/gitlab_filter_test.go | 276 +++++++++++ internal/webhook/handler.go | 107 ++++- internal/webhook/handler_test.go | 199 ++++++++ internal/webhook/signature.go | 13 + internal/webhook/signature_test.go | 22 + internal/workerrunner/runner.go | 18 +- internal/workerrunner/runner_test.go | 21 + opencode/Dockerfile | 12 + skills/kelos/SKILL.md | 2 +- skills/kelos/references/taskspawner.yaml | 33 ++ skills/kelos/references/troubleshooting.md | 6 +- skills/kelos/references/workspace.yaml | 13 + test/integration/taskspawner_test.go | 152 ++++++ 86 files changed, 6753 insertions(+), 348 deletions(-) create mode 100644 examples/19-taskspawner-gitlab/README.md create mode 100644 examples/19-taskspawner-gitlab/credentials-secret.yaml create mode 100644 examples/19-taskspawner-gitlab/gitlab-token-secret.yaml create mode 100644 examples/19-taskspawner-gitlab/gitlab-webhook-secret.yaml create mode 100644 examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml create mode 100644 examples/19-taskspawner-gitlab/taskspawner-webhook.yaml create mode 100644 examples/19-taskspawner-gitlab/taskspawner.yaml create mode 100644 examples/19-taskspawner-gitlab/workspace.yaml create mode 100755 hack/agent-glab-wrapper.sh create mode 100644 internal/controller/workspace_provider.go create mode 100644 internal/controller/workspace_provider_test.go create mode 100644 internal/conversion/workspace_test.go create mode 100644 internal/reporting/gitlab.go create mode 100644 internal/reporting/gitlab_test.go create mode 100644 internal/source/gitlab.go create mode 100644 internal/source/gitlab_comment_policy.go create mode 100644 internal/source/gitlab_comment_policy_test.go create mode 100644 internal/source/gitlab_test.go create mode 100644 internal/webhook/gitlab_filter.go create mode 100644 internal/webhook/gitlab_filter_test.go diff --git a/api/v1alpha2/taskspawner_types.go b/api/v1alpha2/taskspawner_types.go index c6c49da86..f8571e36e 100644 --- a/api/v1alpha2/taskspawner_types.go +++ b/api/v1alpha2/taskspawner_types.go @@ -38,6 +38,10 @@ type When struct { // +optional Jira *Jira `json:"jira,omitempty"` + // GitLab discovers issues and merge requests from a GitLab project. + // +optional + GitLab *GitLab `json:"gitlab,omitempty"` + // GitHubWebhook triggers task spawning on GitHub webhook events. // +optional GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"` @@ -46,6 +50,10 @@ type When struct { // +optional LinearWebhook *LinearWebhook `json:"linearWebhook,omitempty"` + // GitLabWebhook triggers task spawning on GitLab webhook events. + // +optional + GitLabWebhook *GitLabWebhook `json:"gitlabWebhook,omitempty"` + // GenericWebhook triggers task spawning from arbitrary HTTP POST payloads. // Any system that can send an HTTP POST with a JSON body can trigger // tasks through this source. On the per-source server the URL path is @@ -383,6 +391,120 @@ type Jira struct { PollInterval string `json:"pollInterval,omitempty"` } +// GitLabCommentPolicy configures comment-based workflow control on GitLab +// issues and merge requests. Commands are matched against the item +// description and its notes; the most recent matching command wins. +type GitLabCommentPolicy struct { + // TriggerComment requires a matching command for the item to be included. + // When set alone, only items with a matching command are discovered. + // +optional + TriggerComment string `json:"triggerComment,omitempty"` + + // ExcludeComments blocks items whose most recent matching command is an + // exclude command. When combined with TriggerComment, the most recent + // matching command wins. + // +optional + ExcludeComments []string `json:"excludeComments,omitempty"` + + // AllowedUsers restricts comment control to specific GitLab usernames. + // When empty, commands from any user are honored. + // +optional + AllowedUsers []string `json:"allowedUsers,omitempty"` +} + +// GitLabReporting configures status reporting back to the originating GitLab +// issue or merge request. +type GitLabReporting struct { + // Comments configures task status notes on the originating issue or + // merge request. When nil, no notes are posted. + // +optional + Comments *GitLabCommentsReporting `json:"comments,omitempty"` +} + +// GitLabCommentsReporting configures GitLab task status note reporting. +type GitLabCommentsReporting struct { + // Mode controls whether notes are created per Task or reused across + // Tasks from the same TaskSpawner and originating issue or merge request. + // Defaults to PerTask. + // +optional + // +kubebuilder:default=PerTask + // +kubebuilder:validation:Enum=PerTask;Sticky + Mode GitHubCommentMode `json:"mode,omitempty"` +} + +// GitLab discovers issues and merge requests from a GitLab project. +// By default the GitLab instance URL and project path are derived from the +// workspace repo URL in taskTemplate.workspaceRef; set BaseURL or Project to +// override them. The Workspace must set provider: gitlab; its secret's +// GITLAB_TOKEN key is used as the GitLab access token for API calls, the same +// token that authenticates the git clone. +type GitLab struct { + // BaseURL overrides the GitLab instance URL used for API calls (for + // example "https://gitlab.example.com" or an in-cluster service URL). + // When empty, the scheme and host of the workspace repo URL are used. + // +kubebuilder:validation:Pattern="^https?://.+" + // +optional + BaseURL string `json:"baseUrl,omitempty"` + + // Project overrides the project to poll as a full path + // ("group/subgroup/project"). When empty, the project path is derived + // from the workspace repo URL. + // +optional + Project string `json:"project,omitempty"` + + // Types specifies which item types to discover: "issues", + // "mergeRequests", or both. + // +kubebuilder:validation:Items:Enum=issues;mergeRequests + // +kubebuilder:default={"issues"} + // +optional + Types []string `json:"types,omitempty"` + + // Labels filters items by labels; an item must carry all of them. + // +optional + Labels []string `json:"labels,omitempty"` + + // ExcludeLabels filters out items that have any of these labels (client-side). + // +optional + ExcludeLabels []string `json:"excludeLabels,omitempty"` + + // State filters items by state (opened, closed, all). Defaults to opened. + // +kubebuilder:validation:Enum=opened;closed;all + // +kubebuilder:default=opened + // +optional + State string `json:"state,omitempty"` + + // ReviewState filters merge requests by review outcome: "approved" keeps + // merge requests with at least one approval, "changes_requested" keeps + // merge requests where a reviewer requested changes, and "any" does not + // gate discovery. Issues are not affected. + // +kubebuilder:validation:Enum=approved;changes_requested;any + // +kubebuilder:default=any + // +optional + ReviewState string `json:"reviewState,omitempty"` + + // PipelineStatus filters merge requests by the status of their head + // pipeline. A newer pipeline finishing in the selected status retriggers + // completed Tasks. "any" does not gate discovery. Issues are not affected. + // +kubebuilder:validation:Enum=success;failed;running;pending;canceled;any + // +kubebuilder:default=any + // +optional + PipelineStatus string `json:"pipelineStatus,omitempty"` + + // CommentPolicy configures comment-based workflow control. + // +optional + CommentPolicy *GitLabCommentPolicy `json:"commentPolicy,omitempty"` + + // Reporting configures status reporting back to the originating GitLab + // issue or merge request. + // +optional + Reporting *GitLabReporting `json:"reporting,omitempty"` + + // PollInterval is how often this source is polled (e.g., "30s", "5m"). + // When empty, a default of 5m is used. + // +optional + PollInterval string `json:"pollInterval,omitempty"` +} + // GitHubWebhook configures matching for GitHub webhook 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']) || (self.events.exists(e, e == 'issue_comment') && has(self.filters) && self.filters.exists(f, f.event == 'issue_comment') && self.filters.all(f, f.event != 'issue_comment' || (has(f.commentOn) && f.commentOn == 'PullRequest')))",message="checks reporting requires a pull-request event type or PR-scoped issue_comment filters" type GitHubWebhook struct { @@ -577,6 +699,110 @@ type LinearWebhookFilter struct { ExcludeLabels []string `json:"excludeLabels,omitempty"` } +// GitLabWebhook configures webhook-driven task spawning from GitLab events. +// Deliveries are authenticated by comparing the X-Gitlab-Token header with the +// configured webhook secret. +type GitLabWebhook struct { + // Events is the list of GitLab event kinds to listen for, matching the + // payload object_kind: "issue", "merge_request", "note", "pipeline", + // "push", or "tag_push". + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:Items:Enum=issue;merge_request;note;pipeline;push;tag_push + Events []string `json:"events"` + + // GatewayRef binds this source to a WebhookGateway in the same namespace whose + // spec.gitlab field is set. The per-source webhook server ignores this spawner. + // +optional + GatewayRef *GatewayReference `json:"gatewayRef,omitempty"` + + // Project restricts deliveries to one project by its full path + // ("group/subgroup/project"). When empty, events from any project are accepted. + // +optional + Project string `json:"project,omitempty"` + + // ExcludeAuthors excludes events triggered by any of these GitLab usernames. + // This is applied before filter evaluation. + // +optional + ExcludeAuthors []string `json:"excludeAuthors,omitempty"` + + // Filters refine which events match. If multiple filters apply to the same + // event kind, any matching filter accepts the event (OR semantics). + // If empty, all events in the Events list match. + // +optional + Filters []GitLabWebhookFilter `json:"filters,omitempty"` + + // Reporting configures status notes on the originating GitLab issue or + // merge request. Requires a GitLab token on the webhook server + // (GITLAB_TOKEN) or on the bound WebhookGateway (spec.gitlab.credentialsRef). + // +optional + Reporting *GitLabReporting `json:"reporting,omitempty"` +} + +// GitLabWebhookFilter defines filtering criteria for a GitLab webhook event kind. +type GitLabWebhookFilter struct { + // Event is the GitLab event kind this filter applies to. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=issue;merge_request;note;pipeline;push;tag_push + Event string `json:"event"` + + // Action filters issue and merge_request events by the payload action + // (e.g., "open", "update", "close", "reopen", "approved", "unapproved", "merge"). + // +optional + Action string `json:"action,omitempty"` + + // Labels requires the issue or merge request to have all of these labels. + // For note events the labels of the commented issue or merge request are used. + // +optional + Labels []string `json:"labels,omitempty"` + + // ExcludeLabels excludes issues or merge requests with any of these labels. + // +optional + ExcludeLabels []string `json:"excludeLabels,omitempty"` + + // State filters issue and merge_request events by state + // ("opened", "closed", "merged", "locked"). + // +optional + State string `json:"state,omitempty"` + + // Branch filters merge_request events by source branch, and push and + // pipeline events by branch name (exact match or glob). + // +optional + Branch string `json:"branch,omitempty"` + + // Status filters pipeline events by pipeline status + // (e.g., "success", "failed", "canceled", "running", "pending"). + // +optional + Status string `json:"status,omitempty"` + + // NoteOn scopes note events to comments on a specific subject. Omit to + // match notes on any subject. + // +kubebuilder:validation:Enum=Issue;MergeRequest;Commit;Snippet + // +optional + NoteOn string `json:"noteOn,omitempty"` + + // BodyPattern requires the note body to match a Go re2 regular expression. + // +optional + BodyPattern string `json:"bodyPattern,omitempty"` + + // ExcludeBodyPatterns excludes note events whose body matches any of these + // Go re2 regular expressions. + // +optional + ExcludeBodyPatterns []string `json:"excludeBodyPatterns,omitempty"` + + // Draft filters merge_request events by draft status. + // +optional + Draft *bool `json:"draft,omitempty"` + + // Author filters by the username of the user who triggered the event. + // +optional + Author string `json:"author,omitempty"` + + // ExcludeAuthors excludes events triggered by any of these usernames. + // +optional + ExcludeAuthors []string `json:"excludeAuthors,omitempty"` +} + // GenericWebhook configures webhook-driven task spawning from arbitrary HTTP // POST payloads with JSON bodies. Any system that can send an HTTP POST can // trigger tasks through this source. On the per-source server the URL path is @@ -1105,7 +1331,7 @@ type TaskTemplate struct { } // TaskSpawnerSpec defines the desired state of TaskSpawner. -// +kubebuilder:validation:XValidation:rule="!(has(self.when.githubIssues) || has(self.when.githubPullRequests) || has(self.when.githubWebhook) || has(self.when.linearWebhook)) || has(self.taskTemplate.workspaceRef) || (has(self.taskTemplate.worker) && has(self.taskTemplate.worker.workspaceRef)) || has(self.taskTemplate.workerPoolRef)",message="a workspace source is required when using githubIssues, githubPullRequests, githubWebhook, or linearWebhook source (set taskTemplate.workspaceRef, taskTemplate.worker.workspaceRef, or taskTemplate.workerPoolRef — a pool satisfies this because it carries its own workspace)" +// +kubebuilder:validation:XValidation:rule="!(has(self.when.githubIssues) || has(self.when.githubPullRequests) || has(self.when.githubWebhook) || has(self.when.linearWebhook) || has(self.when.gitlab) || has(self.when.gitlabWebhook)) || has(self.taskTemplate.workspaceRef) || (has(self.taskTemplate.worker) && has(self.taskTemplate.worker.workspaceRef)) || has(self.taskTemplate.workerPoolRef)",message="a workspace source is required when using githubIssues, githubPullRequests, githubWebhook, linearWebhook, gitlab, or gitlabWebhook source (set taskTemplate.workspaceRef, taskTemplate.worker.workspaceRef, or taskTemplate.workerPoolRef — a pool satisfies this because it carries its own workspace)" // +kubebuilder:validation:XValidation:rule="has(self.taskTemplate.workerPoolRef) || (has(self.taskTemplate.worker) && (has(self.taskTemplate.worker.credentials) || has(self.credentials))) || (has(self.taskTemplate.type) && (has(self.taskTemplate.credentials) || has(self.credentials)))",message="inline task templates require taskTemplate credentials or spec.credentials" // +kubebuilder:validation:XValidation:rule="!has(self.credentials) || (!has(self.taskTemplate.workerPoolRef) && !has(self.taskTemplate.credentials) && (!has(self.taskTemplate.worker) || !has(self.taskTemplate.worker.credentials)))",message="spec.credentials is mutually exclusive with taskTemplate credentials and workerPoolRef" type TaskSpawnerSpec struct { diff --git a/api/v1alpha2/webhookgateway_types.go b/api/v1alpha2/webhookgateway_types.go index f25b62792..427e99d05 100644 --- a/api/v1alpha2/webhookgateway_types.go +++ b/api/v1alpha2/webhookgateway_types.go @@ -19,9 +19,9 @@ const ( ) // WebhookGatewaySpec defines the desired state of a WebhookGateway. Exactly one -// of GitHub, Linear, or Generic must be set; the field that is present selects -// the webhook source and carries its provider-specific configuration. -// +kubebuilder:validation:XValidation:rule="(has(self.github)?1:0)+(has(self.linear)?1:0)+(has(self.generic)?1:0) == 1",message="exactly one of github, linear, or generic must be set" +// of GitHub, Linear, GitLab, or Generic must be set; the field that is present +// selects the webhook source and carries its provider-specific configuration. +// +kubebuilder:validation:XValidation:rule="(has(self.github)?1:0)+(has(self.linear)?1:0)+(has(self.gitlab)?1:0)+(has(self.generic)?1:0) == 1",message="exactly one of github, linear, gitlab, or generic must be set" type WebhookGatewaySpec struct { // GitHub configures a gateway for GitHub webhook deliveries. // +optional @@ -31,6 +31,10 @@ type WebhookGatewaySpec struct { // +optional Linear *LinearGateway `json:"linear,omitempty"` + // GitLab configures a gateway for GitLab webhook deliveries. + // +optional + GitLab *GitLabGateway `json:"gitlab,omitempty"` + // Generic configures a gateway for arbitrary HTTP POST deliveries. // +optional Generic *GenericGateway `json:"generic,omitempty"` @@ -64,6 +68,29 @@ type LinearGateway struct { SecretRef SecretReference `json:"secretRef"` } +// GitLabGateway configures a GitLab WebhookGateway. GitLab authenticates +// deliveries with a shared secret token sent in the X-Gitlab-Token header +// rather than an HMAC signature. +type GitLabGateway struct { + // SecretRef references a Secret holding the webhook secret token under the + // "webhook-secret" key. + // +kubebuilder:validation:Required + SecretRef SecretReference `json:"secretRef"` + + // APIBaseURL is the GitLab instance URL used for status reporting (for + // example "https://gitlab.example.com" or an in-cluster service URL). When + // empty, the instance URL is taken from the originating webhook payload. + // +kubebuilder:validation:Pattern="^https?://.+" + // +optional + APIBaseURL string `json:"apiBaseURL,omitempty"` + + // CredentialsRef references a Secret holding a GitLab access token under + // the GITLAB_TOKEN key. Required for status reporting on Tasks created + // through this gateway. + // +optional + CredentialsRef *SecretReference `json:"credentialsRef,omitempty"` +} + // GenericGateway configures a generic WebhookGateway. Generic deliveries are // accepted without signature verification, so access must be restricted at the // network layer. diff --git a/api/v1alpha2/workspace_types.go b/api/v1alpha2/workspace_types.go index f4df3af66..48a0adbac 100644 --- a/api/v1alpha2/workspace_types.go +++ b/api/v1alpha2/workspace_types.go @@ -31,7 +31,14 @@ type WorkspaceFile struct { // WorkspaceGHProxy configures the workspace-scoped ghproxy. type WorkspaceGHProxy struct{} +// Supported values of WorkspaceSpec.Provider. +const ( + WorkspaceProviderGitHub = "github" + WorkspaceProviderGitLab = "gitlab" +) + // WorkspaceSpec defines the desired state of Workspace. +// +kubebuilder:validation:XValidation:rule="!has(self.ghproxy) || !has(self.provider) || self.provider == 'github'",message="ghproxy is only supported when provider is github" type WorkspaceSpec struct { // Repo is the git repository URL to clone. // +kubebuilder:validation:Required @@ -43,8 +50,18 @@ type WorkspaceSpec struct { // +optional Ref string `json:"ref,omitempty"` - // SecretRef references a Secret containing a GITHUB_TOKEN key for git - // authentication and GitHub CLI (gh) operations. + // Provider selects the git hosting provider. It determines the key read + // from the SecretRef Secret (GITHUB_TOKEN for github, GITLAB_TOKEN for + // gitlab), the credentials exported to agent containers, and which CLI + // (gh or glab) is preconfigured. + // +kubebuilder:validation:Enum=github;gitlab + // +kubebuilder:default=github + // +optional + Provider string `json:"provider,omitempty"` + + // SecretRef references a Secret containing the Provider's token key + // (GITHUB_TOKEN for github, GITLAB_TOKEN for gitlab) for git + // authentication and CLI operations. // +optional SecretRef *SecretReference `json:"secretRef,omitempty"` diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index e0f983d58..bd899dbbe 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -651,6 +651,209 @@ func (in *GitHubWebhookFilter) DeepCopy() *GitHubWebhookFilter { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitLab) DeepCopyInto(out *GitLab) { + *out = *in + if in.Types != nil { + in, out := &in.Types, &out.Types + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeLabels != nil { + in, out := &in.ExcludeLabels, &out.ExcludeLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.CommentPolicy != nil { + in, out := &in.CommentPolicy, &out.CommentPolicy + *out = new(GitLabCommentPolicy) + (*in).DeepCopyInto(*out) + } + if in.Reporting != nil { + in, out := &in.Reporting, &out.Reporting + *out = new(GitLabReporting) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLab. +func (in *GitLab) DeepCopy() *GitLab { + if in == nil { + return nil + } + out := new(GitLab) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitLabCommentPolicy) DeepCopyInto(out *GitLabCommentPolicy) { + *out = *in + if in.ExcludeComments != nil { + in, out := &in.ExcludeComments, &out.ExcludeComments + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowedUsers != nil { + in, out := &in.AllowedUsers, &out.AllowedUsers + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLabCommentPolicy. +func (in *GitLabCommentPolicy) DeepCopy() *GitLabCommentPolicy { + if in == nil { + return nil + } + out := new(GitLabCommentPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitLabCommentsReporting) DeepCopyInto(out *GitLabCommentsReporting) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLabCommentsReporting. +func (in *GitLabCommentsReporting) DeepCopy() *GitLabCommentsReporting { + if in == nil { + return nil + } + out := new(GitLabCommentsReporting) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitLabGateway) DeepCopyInto(out *GitLabGateway) { + *out = *in + out.SecretRef = in.SecretRef + if in.CredentialsRef != nil { + in, out := &in.CredentialsRef, &out.CredentialsRef + *out = new(SecretReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLabGateway. +func (in *GitLabGateway) DeepCopy() *GitLabGateway { + if in == nil { + return nil + } + out := new(GitLabGateway) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitLabReporting) DeepCopyInto(out *GitLabReporting) { + *out = *in + if in.Comments != nil { + in, out := &in.Comments, &out.Comments + *out = new(GitLabCommentsReporting) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLabReporting. +func (in *GitLabReporting) DeepCopy() *GitLabReporting { + if in == nil { + return nil + } + out := new(GitLabReporting) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitLabWebhook) DeepCopyInto(out *GitLabWebhook) { + *out = *in + if in.Events != nil { + in, out := &in.Events, &out.Events + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.GatewayRef != nil { + in, out := &in.GatewayRef, &out.GatewayRef + *out = new(GatewayReference) + **out = **in + } + if in.ExcludeAuthors != nil { + in, out := &in.ExcludeAuthors, &out.ExcludeAuthors + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Filters != nil { + in, out := &in.Filters, &out.Filters + *out = make([]GitLabWebhookFilter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Reporting != nil { + in, out := &in.Reporting, &out.Reporting + *out = new(GitLabReporting) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLabWebhook. +func (in *GitLabWebhook) DeepCopy() *GitLabWebhook { + if in == nil { + return nil + } + out := new(GitLabWebhook) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitLabWebhookFilter) DeepCopyInto(out *GitLabWebhookFilter) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeLabels != nil { + in, out := &in.ExcludeLabels, &out.ExcludeLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeBodyPatterns != nil { + in, out := &in.ExcludeBodyPatterns, &out.ExcludeBodyPatterns + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Draft != nil { + in, out := &in.Draft, &out.Draft + *out = new(bool) + **out = **in + } + if in.ExcludeAuthors != nil { + in, out := &in.ExcludeAuthors, &out.ExcludeAuthors + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLabWebhookFilter. +func (in *GitLabWebhookFilter) DeepCopy() *GitLabWebhookFilter { + if in == nil { + return nil + } + out := new(GitLabWebhookFilter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitRemote) DeepCopyInto(out *GitRemote) { *out = *in @@ -2189,6 +2392,11 @@ func (in *WebhookGatewaySpec) DeepCopyInto(out *WebhookGatewaySpec) { *out = new(LinearGateway) **out = **in } + if in.GitLab != nil { + in, out := &in.GitLab, &out.GitLab + *out = new(GitLabGateway) + (*in).DeepCopyInto(*out) + } if in.Generic != nil { in, out := &in.Generic, &out.Generic *out = new(GenericGateway) @@ -2244,6 +2452,11 @@ func (in *When) DeepCopyInto(out *When) { *out = new(Jira) **out = **in } + if in.GitLab != nil { + in, out := &in.GitLab, &out.GitLab + *out = new(GitLab) + (*in).DeepCopyInto(*out) + } if in.GitHubWebhook != nil { in, out := &in.GitHubWebhook, &out.GitHubWebhook *out = new(GitHubWebhook) @@ -2254,6 +2467,11 @@ func (in *When) DeepCopyInto(out *When) { *out = new(LinearWebhook) (*in).DeepCopyInto(*out) } + if in.GitLabWebhook != nil { + in, out := &in.GitLabWebhook, &out.GitLabWebhook + *out = new(GitLabWebhook) + (*in).DeepCopyInto(*out) + } if in.GenericWebhook != nil { in, out := &in.GenericWebhook, &out.GenericWebhook *out = new(GenericWebhook) diff --git a/claude-code/Dockerfile b/claude-code/Dockerfile index 22c04fb0a..a22d1849c 100644 --- a/claude-code/Dockerfile +++ b/claude-code/Dockerfile @@ -26,6 +26,15 @@ RUN ARCH=$(dpkg --print-architecture) \ && tar -C /usr/local -xzf "/tmp/${TARBALL}" \ && rm "/tmp/${TARBALL}" "/tmp/${TARBALL}.sha256" +ARG GLAB_VERSION=1.116.0 +RUN ARCH=$(dpkg --print-architecture) \ + && DEB="glab_${GLAB_VERSION}_linux_${ARCH}.deb" \ + && curl -fsSL -o "/tmp/${DEB}" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${DEB}" \ + && curl -fsSL -o /tmp/glab-checksums.txt "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/checksums.txt" \ + && (cd /tmp && sha256sum --ignore-missing -c glab-checksums.txt) \ + && dpkg -i "/tmp/${DEB}" \ + && rm "/tmp/${DEB}" /tmp/glab-checksums.txt + ENV PATH="/usr/local/go/bin:${PATH}" ARG CLAUDE_CODE_VERSION=2.1.258 @@ -37,6 +46,9 @@ RUN chmod +x /kelos_entrypoint.sh COPY hack/agent-gh-wrapper.sh /usr/local/bin/gh RUN chmod +x /usr/local/bin/gh +COPY hack/agent-glab-wrapper.sh /usr/local/bin/glab +RUN chmod +x /usr/local/bin/glab + ARG TARGETARCH COPY bin/kelos-capture-linux-${TARGETARCH} /kelos/kelos-capture diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 8dcef883a..dd62ec200 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -54,6 +54,8 @@ func main() { var jiraBaseURL string var jiraProject string var jiraJQL string + var gitlabBaseURL string + var gitlabProject string var oneShot bool flag.StringVar(&name, "taskspawner-name", "", "Name of the TaskSpawner to manage") @@ -69,6 +71,8 @@ func main() { flag.StringVar(&jiraBaseURL, "jira-base-url", "", "Jira instance base URL (e.g. https://mycompany.atlassian.net)") flag.StringVar(&jiraProject, "jira-project", "", "Jira project key") flag.StringVar(&jiraJQL, "jira-jql", "", "Optional JQL filter for Jira issues") + flag.StringVar(&gitlabBaseURL, "gitlab-base-url", "", "GitLab instance base URL (e.g. https://gitlab.example.com)") + flag.StringVar(&gitlabProject, "gitlab-project", "", "GitLab project path (e.g. group/subgroup/project)") flag.BoolVar(&oneShot, "one-shot", false, "Run a single discovery cycle and exit (used by CronJob)") opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) @@ -122,6 +126,9 @@ func main() { httpClient := &http.Client{Transport: source.NewMetricsTransport(http.DefaultTransport)} tokenResolver := newGitHubTokenResolver(githubToken, githubAppID, githubAppInstallationID, githubAppPrivateKey, githubAPIBaseURL) + if gitlabProject != "" { + tokenResolver = newGitLabTokenResolver(os.Getenv("GITLAB_TOKEN")) + } reportingGitHubAppID := "" if githubToken == "" && githubAppID != "" && githubAppInstallationID != "" && githubAppPrivateKey != "" { reportingGitHubAppID = githubAppID @@ -137,6 +144,8 @@ func main() { JiraBaseURL: jiraBaseURL, JiraProject: jiraProject, JiraJQL: jiraJQL, + GitLabBaseURL: gitlabBaseURL, + GitLabProject: gitlabProject, HTTPClient: httpClient, } @@ -205,12 +214,12 @@ func taskNameForWorkItem(taskSpawnerName, workItemID string) string { } func runCycle(ctx context.Context, cl client.Client, key types.NamespacedName, githubOwner, githubRepo, githubAPIBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client) error { - return runCycleWithProxy(ctx, cl, key, githubOwner, githubRepo, "", githubAPIBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, httpClient) + return runCycleWithProxy(ctx, cl, key, githubOwner, githubRepo, "", githubAPIBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, "", "", httpClient) } -func runCycleWithProxy(ctx context.Context, cl client.Client, key types.NamespacedName, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client) error { +func runCycleWithProxy(ctx context.Context, cl client.Client, key types.NamespacedName, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL, gitlabBaseURL, gitlabProject string, httpClient *http.Client) error { start := time.Now() - err := runCycleCore(ctx, cl, key, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, httpClient) + err := runCycleCore(ctx, cl, key, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, gitlabBaseURL, gitlabProject, httpClient) discoveryDurationSeconds.Observe(time.Since(start).Seconds()) if err != nil { discoveryErrorsTotal.Inc() @@ -218,13 +227,13 @@ func runCycleWithProxy(ctx context.Context, cl client.Client, key types.Namespac return err } -func runCycleCore(ctx context.Context, cl client.Client, key types.NamespacedName, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client) error { +func runCycleCore(ctx context.Context, cl client.Client, key types.NamespacedName, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL, gitlabBaseURL, gitlabProject string, httpClient *http.Client) error { var ts kelos.TaskSpawner if err := cl.Get(ctx, key, &ts); err != nil { return fmt.Errorf("fetching TaskSpawner: %w", err) } - src, err := buildSourceWithProxy(ctx, &ts, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, httpClient) + src, err := buildSourceWithProxy(ctx, &ts, githubOwner, githubRepo, ghProxyURL, githubAPIBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, gitlabBaseURL, gitlabProject, httpClient) if err != nil { return fmt.Errorf("building source: %w", err) } @@ -613,17 +622,21 @@ func recordCycleFailure(ctx context.Context, cl client.Client, key types.Namespa return cycleErr } -// sourceAnnotations returns annotations that stamp GitHub source metadata -// onto a spawned Task. These annotations enable downstream consumers (such -// as the reporting watcher) to identify the originating issue or PR. +// sourceAnnotations returns annotations that stamp GitHub or GitLab source +// metadata onto a spawned Task. These annotations enable downstream consumers +// (such as the reporting watcher) to identify the originating issue, pull +// request, or merge request. func sourceAnnotations(ts *kelos.TaskSpawner, item source.WorkItem) map[string]string { - if ts.Spec.When.GitHubIssues == nil && ts.Spec.When.GitHubPullRequests == nil { + if ts.Spec.When.GitHubIssues == nil && ts.Spec.When.GitHubPullRequests == nil && ts.Spec.When.GitLab == nil { return nil } kind := "issue" - if item.Kind == "PR" { + switch item.Kind { + case "PR": kind = "pull-request" + case "MR": + kind = reporting.SourceKindMergeRequest } annotations := map[string]string{ @@ -649,10 +662,10 @@ func sourceAnnotations(ts *kelos.TaskSpawner, item source.WorkItem) map[string]s return annotations } -// 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. +// reportingEnabled returns true when GitHub or GitLab comment reporting is +// configured and enabled on the TaskSpawner. This only covers polling-based +// sources; webhook-based reporting is handled by the webhook server and its +// handler. func reportingEnabled(ts *kelos.TaskSpawner) bool { if ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Reporting != nil { rep := ts.Spec.When.GitHubIssues.Reporting @@ -662,20 +675,26 @@ func reportingEnabled(ts *kelos.TaskSpawner) bool { rep := ts.Spec.When.GitHubPullRequests.Reporting return rep.Enabled || rep.Comments != nil } + if ts.Spec.When.GitLab != nil && ts.Spec.When.GitLab.Reporting != nil { + return ts.Spec.When.GitLab.Reporting.Comments != nil + } return false } // resolvedCommentMode returns the configured comment mode. The deprecated // Enabled field and an empty Comments configuration retain PerTask behavior. func resolvedCommentMode(ts *kelos.TaskSpawner) kelos.GitHubCommentMode { - var rep *kelos.GitHubReporting - if ts.Spec.When.GitHubIssues != nil { - rep = ts.Spec.When.GitHubIssues.Reporting - } else if ts.Spec.When.GitHubPullRequests != nil { - rep = ts.Spec.When.GitHubPullRequests.Reporting - } - if rep != nil && rep.Comments != nil && rep.Comments.Mode != "" { - return rep.Comments.Mode + var mode kelos.GitHubCommentMode + switch { + case ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Reporting != nil && ts.Spec.When.GitHubIssues.Reporting.Comments != nil: + mode = ts.Spec.When.GitHubIssues.Reporting.Comments.Mode + case ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.Reporting != nil && ts.Spec.When.GitHubPullRequests.Reporting.Comments != nil: + mode = ts.Spec.When.GitHubPullRequests.Reporting.Comments.Mode + case ts.Spec.When.GitLab != nil && ts.Spec.When.GitLab.Reporting != nil && ts.Spec.When.GitLab.Reporting.Comments != nil: + mode = ts.Spec.When.GitLab.Reporting.Comments.Mode + } + if mode != "" { + return mode } return kelos.GitHubCommentModePerTask } @@ -733,10 +752,10 @@ func resolveGitHubCommentPolicy(policy *kelos.GitHubCommentPolicy) resolvedGitHu } func buildSource(ctx context.Context, ts *kelos.TaskSpawner, owner, repo, apiBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client) (source.Source, error) { - return buildSourceWithProxy(ctx, ts, owner, repo, "", apiBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, httpClient) + return buildSourceWithProxy(ctx, ts, owner, repo, "", apiBaseURL, tokenResolver, jiraBaseURL, jiraProject, jiraJQL, "", "", httpClient) } -func buildSourceWithProxy(ctx context.Context, ts *kelos.TaskSpawner, owner, repo, ghProxyURL, apiBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client) (source.Source, error) { +func buildSourceWithProxy(ctx context.Context, ts *kelos.TaskSpawner, owner, repo, ghProxyURL, apiBaseURL string, tokenResolver func(context.Context) (string, error), jiraBaseURL, jiraProject, jiraJQL, gitlabBaseURL, gitlabProject string, httpClient *http.Client) (source.Source, error) { if ts.Spec.When.GitHubIssues != nil { gh := ts.Spec.When.GitHubIssues commentPolicy := resolveGitHubCommentPolicy(gh.CommentPolicy) @@ -828,6 +847,35 @@ func buildSourceWithProxy(ctx context.Context, ts *kelos.TaskSpawner, owner, rep }, nil } + if ts.Spec.When.GitLab != nil { + gl := ts.Spec.When.GitLab + // GITLAB_TOKEN is injected from the Workspace secret's GITLAB_TOKEN key + // by the TaskSpawner controller; polling unauthenticated is not + // supported. + token := os.Getenv("GITLAB_TOKEN") + if token == "" { + return nil, fmt.Errorf("GITLAB_TOKEN is not set; the Workspace secret must provide a GITLAB_TOKEN key") + } + src := &source.GitLabSource{ + BaseURL: gitlabBaseURL, + Project: gitlabProject, + Types: gl.Types, + Labels: gl.Labels, + ExcludeLabels: gl.ExcludeLabels, + State: gl.State, + ReviewState: gl.ReviewState, + PipelineStatus: gl.PipelineStatus, + Token: token, + Client: httpClient, + } + if gl.CommentPolicy != nil { + src.TriggerComment = gl.CommentPolicy.TriggerComment + src.ExcludeComments = gl.CommentPolicy.ExcludeComments + src.AllowedUsers = gl.CommentPolicy.AllowedUsers + } + return src, nil + } + if ts.Spec.When.Cron != nil { var lastDiscovery time.Time if ts.Status.LastDiscoveryTime != nil { @@ -871,6 +919,16 @@ func newGitHubTokenResolver(token, appID, installID, privateKey, apiBaseURL stri return githubapp.NewTokenProvider(tc, creds).Token } +// newGitLabTokenResolver returns a resolver for the static GITLAB_TOKEN that +// GitLab note reporting uses. It is nil when the token is unset so reporting +// fails fast instead of posting unauthenticated. +func newGitLabTokenResolver(token string) func(context.Context) (string, error) { + if token == "" { + return nil + } + return func(context.Context) (string, error) { return token, nil } +} + func priorityLabelsForTaskSpawner(ts *kelos.TaskSpawner) []string { if ts.Spec.When.GitHubIssues != nil { return ts.Spec.When.GitHubIssues.PriorityLabels diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index d330f8c69..0b579e204 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -389,6 +389,103 @@ func TestBuildSource_Jira(t *testing.T) { } } +func TestBuildSource_GitLab(t *testing.T) { + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "spawner", Namespace: "default"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{ + GitLab: &kelos.GitLab{ + Types: []string{"issues", "mergeRequests"}, + Labels: []string{"kelos"}, + ExcludeLabels: []string{"wontfix"}, + State: "all", + ReviewState: "changes_requested", + PipelineStatus: "failed", + CommentPolicy: &kelos.GitLabCommentPolicy{ + TriggerComment: "/kelos fix", + ExcludeComments: []string{"/kelos stop"}, + AllowedUsers: []string{"alice"}, + }, + }, + }, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + WorkspaceRef: &kelos.WorkspaceReference{Name: "ws"}, + Credentials: &kelos.Credentials{ + Type: kelos.CredentialTypeOAuth, + SecretRef: &kelos.SecretReference{Name: "creds"}, + }, + }, + }, + } + + t.Setenv("GITLAB_TOKEN", "glpat-token") + // A GitHub token resolver must never leak into the GitLab source. + githubToken := func(context.Context) (string, error) { return "ghp-token", nil } + src, err := buildSourceWithProxy(context.Background(), ts, "", "", "", "", githubToken, "", "", "", "https://gitlab.example.com", "group/sub/repo", nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + glSrc, ok := src.(*source.GitLabSource) + if !ok { + t.Fatalf("Expected *source.GitLabSource, got %T", src) + } + if glSrc.BaseURL != "https://gitlab.example.com" || glSrc.Project != "group/sub/repo" { + t.Errorf("BaseURL/Project = %q/%q, want flags passed through", glSrc.BaseURL, glSrc.Project) + } + if glSrc.Token != "glpat-token" { + t.Errorf("Token = %q, want token from GITLAB_TOKEN", glSrc.Token) + } + if len(glSrc.Types) != 2 || glSrc.State != "all" { + t.Errorf("Types/State = %v/%q, want spec values", glSrc.Types, glSrc.State) + } + if glSrc.ReviewState != "changes_requested" || glSrc.PipelineStatus != "failed" { + t.Errorf("ReviewState/PipelineStatus = %q/%q, want spec values", glSrc.ReviewState, glSrc.PipelineStatus) + } + if len(glSrc.Labels) != 1 || glSrc.Labels[0] != "kelos" || len(glSrc.ExcludeLabels) != 1 || glSrc.ExcludeLabels[0] != "wontfix" { + t.Errorf("Labels/ExcludeLabels = %v/%v", glSrc.Labels, glSrc.ExcludeLabels) + } + if glSrc.TriggerComment != "/kelos fix" || len(glSrc.ExcludeComments) != 1 || len(glSrc.AllowedUsers) != 1 || glSrc.AllowedUsers[0] != "alice" { + t.Errorf("comment policy not propagated: %+v", glSrc) + } +} + +func TestBuildSource_GitLabWithoutToken(t *testing.T) { + t.Setenv("GITLAB_TOKEN", "") + ts := &kelos.TaskSpawner{ + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{}}, + }, + } + // Even a GitHub token resolver does not stand in for the missing GitLab token. + githubToken := func(context.Context) (string, error) { return "ghp-token", nil } + _, err := buildSourceWithProxy(context.Background(), ts, "", "", "", "", githubToken, "", "", "", "https://gitlab.example.com", "group/repo", nil) + if err == nil || !strings.Contains(err.Error(), "GITLAB_TOKEN is not set") { + t.Fatalf("expected missing GITLAB_TOKEN error, got %v", err) + } +} + +func TestBuildSource_GitLabEmptySpec(t *testing.T) { + t.Setenv("GITLAB_TOKEN", "glpat-token") + ts := &kelos.TaskSpawner{ + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{}}, + }, + } + src, err := buildSourceWithProxy(context.Background(), ts, "", "", "", "", nil, "", "", "", "https://gitlab.example.com", "group/repo", nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + glSrc := src.(*source.GitLabSource) + if glSrc.Token != "glpat-token" { + t.Errorf("Token = %q, want GITLAB_TOKEN value", glSrc.Token) + } + if len(glSrc.Types) != 0 || glSrc.TriggerComment != "" { + t.Errorf("expected zero-value source for empty spec, got %+v", glSrc) + } +} + func TestRunCycleWithSource_NoMaxConcurrency(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) cl, key := setupTest(t, ts) @@ -1976,6 +2073,54 @@ func TestSourceAnnotations_GitHubPR(t *testing.T) { } } +func TestSourceAnnotations_GitLab(t *testing.T) { + ts := &kelos.TaskSpawner{ + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{ + GitLab: &kelos.GitLab{ + Reporting: &kelos.GitLabReporting{ + Comments: &kelos.GitLabCommentsReporting{Mode: kelos.GitHubCommentModeSticky}, + }, + }, + }, + }, + } + + issue := sourceAnnotations(ts, source.WorkItem{ID: "42", Number: 42, Kind: "Issue"}) + if issue[reporting.AnnotationSourceKind] != "issue" || issue[reporting.AnnotationSourceNumber] != "42" { + t.Errorf("unexpected issue annotations: %v", issue) + } + if issue[reporting.AnnotationGitHubReporting] != "enabled" { + t.Errorf("Expected reporting enabled annotation, got %v", issue) + } + if issue[reporting.AnnotationGitHubCommentMode] != string(kelos.GitHubCommentModeSticky) { + t.Errorf("Expected sticky comment mode, got %q", issue[reporting.AnnotationGitHubCommentMode]) + } + + mr := sourceAnnotations(ts, source.WorkItem{ID: "mr-7", Number: 7, Kind: "MR", HeadSHA: "abc"}) + if mr[reporting.AnnotationSourceKind] != reporting.SourceKindMergeRequest || mr[reporting.AnnotationSourceNumber] != "7" { + t.Errorf("unexpected merge request annotations: %v", mr) + } + if _, ok := mr[reporting.AnnotationGitHubChecks]; ok { + t.Error("Expected no checks annotation for GitLab source") + } +} + +func TestSourceAnnotations_GitLabReportingDisabled(t *testing.T) { + ts := &kelos.TaskSpawner{ + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{Reporting: &kelos.GitLabReporting{}}}, + }, + } + annotations := sourceAnnotations(ts, source.WorkItem{ID: "1", Number: 1, Kind: "Issue"}) + if annotations[reporting.AnnotationSourceNumber] != "1" { + t.Errorf("Expected source annotations even without reporting, got %v", annotations) + } + if _, ok := annotations[reporting.AnnotationGitHubReporting]; ok { + t.Error("Expected no reporting annotation when comments reporting is not configured") + } +} + func TestSourceAnnotations_ReportingEnabled(t *testing.T) { ts := &kelos.TaskSpawner{ Spec: kelos.TaskSpawnerSpec{ @@ -2275,6 +2420,26 @@ func TestReportingEnabled_Jira(t *testing.T) { } } +func TestReportingEnabled_GitLab(t *testing.T) { + disabled := &kelos.TaskSpawner{Spec: kelos.TaskSpawnerSpec{When: kelos.When{GitLab: &kelos.GitLab{}}}} + if reportingEnabled(disabled) { + t.Error("Expected reporting to be disabled for GitLab source without reporting") + } + if checksReportingEnabled(disabled) { + t.Error("Expected checks reporting to be unsupported for GitLab source") + } + + enabled := &kelos.TaskSpawner{Spec: kelos.TaskSpawnerSpec{When: kelos.When{GitLab: &kelos.GitLab{ + Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{}}, + }}}} + if !reportingEnabled(enabled) { + t.Error("Expected reporting to be enabled for GitLab comments reporting") + } + if got := resolvedCommentMode(enabled); got != kelos.GitHubCommentModePerTask { + t.Errorf("resolvedCommentMode = %q, want PerTask default", got) + } +} + func TestChecksReportingEnabled_PREnabled(t *testing.T) { ts := &kelos.TaskSpawner{ Spec: kelos.TaskSpawnerSpec{ @@ -2658,6 +2823,33 @@ func TestRunOnce_ErrorsWhenReportingEnabledWithoutTokenResolver(t *testing.T) { } } +func TestRunOnce_GitLabReportingUsesGitLabTokenResolver(t *testing.T) { + ts := newTaskSpawner("spawner", "default", nil) + ts.Spec.Suspend = boolPtr(true) + ts.Spec.When = kelos.When{GitLab: &kelos.GitLab{ + Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{}}, + }} + + cl, key := setupTest(t, ts) + t.Setenv("GITLAB_TOKEN", "glpat-token") + + if _, err := runOnce(context.Background(), cl, key, spawnerRuntimeConfig{ + GitLabBaseURL: "https://gitlab.example.com", + GitLabProject: "group/repo", + TokenResolver: newGitLabTokenResolver(""), + }); err == nil || !strings.Contains(err.Error(), "no token resolver") { + t.Fatalf("expected missing token resolver error without GITLAB_TOKEN, got %v", err) + } + + if _, err := runOnce(context.Background(), cl, key, spawnerRuntimeConfig{ + GitLabBaseURL: "https://gitlab.example.com", + GitLabProject: "group/repo", + TokenResolver: newGitLabTokenResolver("glpat-token"), + }); err != nil { + t.Fatalf("unexpected error with GITLAB_TOKEN resolver: %v", err) + } +} + func TestSpawnerReconcilerTaskSpawnerPredicate(t *testing.T) { key := types.NamespacedName{Name: "spawner", Namespace: "default"} r := &spawnerReconciler{Key: key} @@ -2825,6 +3017,17 @@ func TestResolvedPollInterval_JiraSourceOverride(t *testing.T) { } } +func TestResolvedPollInterval_GitLabSourceOverride(t *testing.T) { + ts := &kelos.TaskSpawner{ + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{PollInterval: "90s"}}, + }, + } + if got := resolvedPollInterval(ts); got != 90*time.Second { + t.Fatalf("resolvedPollInterval = %v, want %v", got, 90*time.Second) + } +} + func TestResolvedPollInterval_CronUsesDefault(t *testing.T) { ts := &kelos.TaskSpawner{ Spec: kelos.TaskSpawnerSpec{ diff --git a/cmd/kelos-spawner/reconciler.go b/cmd/kelos-spawner/reconciler.go index c52611841..9f44deb4a 100644 --- a/cmd/kelos-spawner/reconciler.go +++ b/cmd/kelos-spawner/reconciler.go @@ -31,6 +31,8 @@ type spawnerRuntimeConfig struct { JiraBaseURL string JiraProject string JiraJQL string + GitLabBaseURL string + GitLabProject string HTTPClient *http.Client } @@ -70,7 +72,7 @@ func (r *spawnerReconciler) SetupWithManager(mgr ctrl.Manager) error { } func runOnce(ctx context.Context, cl client.Client, key types.NamespacedName, cfg spawnerRuntimeConfig) (time.Duration, error) { - if err := runCycleWithProxy(ctx, cl, key, cfg.GitHubOwner, cfg.GitHubRepo, cfg.GHProxyURL, cfg.GitHubAPIBaseURL, cfg.TokenResolver, cfg.JiraBaseURL, cfg.JiraProject, cfg.JiraJQL, cfg.HTTPClient); err != nil { + if err := runCycleWithProxy(ctx, cl, key, cfg.GitHubOwner, cfg.GitHubRepo, cfg.GHProxyURL, cfg.GitHubAPIBaseURL, cfg.TokenResolver, cfg.JiraBaseURL, cfg.JiraProject, cfg.JiraJQL, cfg.GitLabBaseURL, cfg.GitLabProject, cfg.HTTPClient); err != nil { return 0, err } @@ -81,28 +83,35 @@ func runOnce(ctx context.Context, cl client.Client, key types.NamespacedName, cf if reportingEnabled(&ts) || checksReportingEnabled(&ts) { if cfg.TokenResolver == nil { - return 0, fmt.Errorf("GitHub reporting is enabled but no token resolver is configured") + return 0, fmt.Errorf("reporting is enabled but no token resolver is configured") } resolve := cfg.TokenResolver tokenFunc := func() string { token, err := resolve(ctx) if err != nil { - ctrl.Log.WithName("spawner").Error(err, "Resolving GitHub token for reporting") + ctrl.Log.WithName("spawner").Error(err, "Resolving token for reporting") return "" } return token } // Reporting always uses the direct API base URL (writes bypass the proxy). - reporter := &reporting.TaskReporter{ - Client: cl, - Reporter: &reporting.GitHubReporter{ + reporter := &reporting.TaskReporter{Client: cl} + if ts.Spec.When.GitLab != nil { + reporter.Reporter = &reporting.GitLabReporter{ + BaseURL: cfg.GitLabBaseURL, + Project: cfg.GitLabProject, + TokenFunc: tokenFunc, + Client: cfg.HTTPClient, + } + } else { + reporter.Reporter = &reporting.GitHubReporter{ Owner: cfg.GitHubOwner, Repo: cfg.GitHubRepo, TokenFunc: tokenFunc, GitHubAppID: cfg.GitHubAppID, BaseURL: cfg.GitHubAPIBaseURL, Client: cfg.HTTPClient, - }, + } } if checksReportingEnabled(&ts) { reporter.ChecksReporter = &reporting.ChecksReporter{ @@ -132,6 +141,8 @@ func resolvedPollInterval(ts *kelos.TaskSpawner) time.Duration { sourceInterval = ts.Spec.When.GitHubPullRequests.PollInterval case ts.Spec.When.Jira != nil: sourceInterval = ts.Spec.When.Jira.PollInterval + case ts.Spec.When.GitLab != nil: + sourceInterval = ts.Spec.When.GitLab.PollInterval } if sourceInterval != "" { return parsePollInterval(sourceInterval) diff --git a/cmd/kelos-webhook-server/main.go b/cmd/kelos-webhook-server/main.go index 2db6149f8..af0e092dc 100644 --- a/cmd/kelos-webhook-server/main.go +++ b/cmd/kelos-webhook-server/main.go @@ -49,9 +49,10 @@ func main() { githubAppPrivateKey string githubAPIBaseURL string githubTokenFile string + gitlabToken string ) - flag.StringVar(&source, "source", "", "Webhook source type (github, linear, or generic). Ignored when --gateway-mode is set.") + flag.StringVar(&source, "source", "", "Webhook source type (github, linear, gitlab, or generic). Ignored when --gateway-mode is set.") flag.BoolVar(&gatewayMode, "gateway-mode", false, "Serve WebhookGateway resources at /webhook// paths.") flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -63,6 +64,7 @@ func main() { flag.StringVar(&githubAppPrivateKey, "github-app-private-key", "", "GitHub App private key in PEM format (env: GITHUB_APP_PRIVATE_KEY)") flag.StringVar(&githubAPIBaseURL, "github-api-base-url", "", "GitHub API base URL for enterprise servers (env: GITHUB_API_BASE_URL)") flag.StringVar(&githubTokenFile, "github-token-file", "", "Path to file containing GitHub token for reporting.") + flag.StringVar(&gitlabToken, "gitlab-token", "", "GitLab access token for status notes in --source=gitlab mode (env: GITLAB_TOKEN)") opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) flag.Parse() @@ -78,6 +80,9 @@ func main() { if githubToken == "" { githubToken = os.Getenv("GITHUB_TOKEN") } + if gitlabToken == "" { + gitlabToken = os.Getenv("GITLAB_TOKEN") + } if githubAppID == "" { githubAppID = os.Getenv("GITHUB_APP_ID") } @@ -100,11 +105,13 @@ func main() { webhookSource = webhook.GitHubSource case "linear": webhookSource = webhook.LinearSource + case "gitlab": + webhookSource = webhook.GitLabSource case "generic": webhookSource = webhook.GenericSource default: setupLog.Error(fmt.Errorf("invalid source: %s", source), - "Source must be 'github', 'linear', or 'generic'") + "Source must be 'github', 'linear', 'gitlab', or 'generic'") os.Exit(1) } } @@ -234,15 +241,17 @@ func main() { // or in per-source GitHub mode when a token resolver is available. Owner and repo // come from per-Task annotations stamped by the webhook handler from the // originating event payload, so one server can report against many - // repositories. Linear and generic sources never produce GitHub-reporting - // tasks, so the reconciler stays disabled there. - if gatewayMode || (webhookSource == webhook.GitHubSource && tokenResolver != nil) { + // repositories. The GitLab per-source server reports with --gitlab-token. + // Linear and generic sources never produce reporting tasks, so the + // reconciler stays disabled there. + if gatewayMode || (webhookSource == webhook.GitHubSource && tokenResolver != nil) || (webhookSource == webhook.GitLabSource && gitlabToken != "") { reportingReconciler := &reportingReconciler{ Client: mgr.GetClient(), config: reportingConfig{ TokenResolver: tokenResolver, GitHubAPIBaseURL: githubAPIBaseURL, GitHubAppID: reportingGitHubAppID, + GitLabToken: gitlabToken, GatewayMode: gatewayMode, }, } @@ -254,6 +263,9 @@ func main() { } else if webhookSource == webhook.GitHubSource { setupLog.Info("Reporting controller disabled: no GitHub credentials configured. " + "Set --github-token, --github-app-* flags, or --github-token-file to enable status reporting on Tasks") + } else if webhookSource == webhook.GitLabSource { + setupLog.Info("Reporting controller disabled: no GitLab token configured. " + + "Set --gitlab-token or GITLAB_TOKEN to enable status notes on Tasks") } // Add health checks diff --git a/cmd/kelos-webhook-server/reporting.go b/cmd/kelos-webhook-server/reporting.go index 4a05b1a2c..f88a20e4d 100644 --- a/cmd/kelos-webhook-server/reporting.go +++ b/cmd/kelos-webhook-server/reporting.go @@ -28,7 +28,10 @@ type reportingConfig struct { TokenResolver func(context.Context) (string, error) GitHubAPIBaseURL string GitHubAppID string - GatewayMode bool + // GitLabToken authenticates status notes in --source=gitlab mode. Gateway + // mode resolves the token from the gateway's credentialsRef instead. + GitLabToken string + GatewayMode bool } // reportingReconciler watches Tasks with GitHub reporting annotations @@ -66,6 +69,10 @@ func (r *reportingReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, nil } + if task.Annotations[reporting.AnnotationSourceProvider] == reporting.SourceProviderGitLab { + return r.reportGitLab(ctx, &task) + } + owner := task.Annotations[reporting.AnnotationSourceOwner] repo := task.Annotations[reporting.AnnotationSourceRepo] if owner == "" || repo == "" { @@ -116,6 +123,73 @@ func (r *reportingReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, nil } +// reportGitLab posts status notes for a Task created from a GitLab webhook. +// The project path and instance URL come from Task annotations; the token +// comes from the bound gateway's credentialsRef or the server's GitLab token. +func (r *reportingReconciler) reportGitLab(ctx context.Context, task *kelos.Task) (ctrl.Result, error) { + log := ctrl.Log.WithName("reporting") + + project := task.Annotations[reporting.AnnotationSourceRepo] + baseURL := task.Annotations[reporting.AnnotationSourceBaseURL] + if project == "" || baseURL == "" { + log.Info("Skipping reporting: missing source project/base-url annotation", "task", task.Name) + return ctrl.Result{}, nil + } + + token, apiBaseURL, err := r.resolveGitLabReportingCreds(ctx, task) + if err != nil { + log.Error(err, "Resolving GitLab credentials for reporting", "task", task.Name) + return ctrl.Result{}, fmt.Errorf("resolving reporting credentials: %w", err) + } + if apiBaseURL != "" { + baseURL = apiBaseURL + } + + reporter := &reporting.TaskReporter{ + Client: r.Client, + Reporter: &reporting.GitLabReporter{ + BaseURL: baseURL, + Project: project, + Token: token, + }, + Cache: r.cache, + } + if err := reporter.ReportTaskStatus(ctx, task); err != nil { + log.Error(err, "Reporting task status", "task", task.Name) + return ctrl.Result{}, fmt.Errorf("reporting task status: %w", err) + } + return ctrl.Result{}, nil +} + +// resolveGitLabReportingCreds returns the GitLab token and, for gateway-owned +// Tasks, the gateway's API base URL override (empty when not configured). +func (r *reportingReconciler) resolveGitLabReportingCreds(ctx context.Context, task *kelos.Task) (string, string, error) { + gwName := task.Annotations[reporting.AnnotationWebhookGateway] + if gwName == "" { + if r.config.GitLabToken == "" { + return "", "", fmt.Errorf("no GitLab token configured for reporting") + } + return r.config.GitLabToken, "", nil + } + + var gw kelos.WebhookGateway + if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: gwName}, &gw); err != nil { + return "", "", fmt.Errorf("fetching webhook gateway %s: %w", gwName, err) + } + if gw.Spec.GitLab == nil || gw.Spec.GitLab.CredentialsRef == nil { + return "", "", fmt.Errorf("webhook gateway %s has no gitlab.credentialsRef for reporting", gwName) + } + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Namespace: task.Namespace, Name: gw.Spec.GitLab.CredentialsRef.Name}, &secret); err != nil { + return "", "", fmt.Errorf("fetching webhook gateway credentials %s: %w", gw.Spec.GitLab.CredentialsRef.Name, err) + } + token := strings.TrimSpace(string(secret.Data["GITLAB_TOKEN"])) + if token == "" { + return "", "", fmt.Errorf("webhook gateway %s credentials contain no GITLAB_TOKEN", gwName) + } + return token, gw.Spec.GitLab.APIBaseURL, nil +} + // resolveReportingCreds returns the GitHub token resolver, API base URL, and // GitHub App ID to use for reporting on the given Task. When the Task was // created via a WebhookGateway, these values are resolved from that gateway so diff --git a/cmd/kelos-webhook-server/reporting_test.go b/cmd/kelos-webhook-server/reporting_test.go index f0896503a..5e90a2872 100644 --- a/cmd/kelos-webhook-server/reporting_test.go +++ b/cmd/kelos-webhook-server/reporting_test.go @@ -127,6 +127,145 @@ func TestResolveReportingCredsFromGateway(t *testing.T) { } } +func TestReportingReconcilerPostsGitLabNote(t *testing.T) { + var gotPath, gotToken string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + gotToken = r.Header.Get("PRIVATE-TOKEN") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]int64{"id": 77}) + })) + defer server.Close() + + tests := []struct { + name string + gatewayMode bool + objects []client.Object + annotations map[string]string + config reportingConfig + wantToken string + }{ + { + name: "per-source server uses the configured GitLab token and the payload instance URL", + annotations: map[string]string{ + reporting.AnnotationSourceBaseURL: server.URL, + }, + config: reportingConfig{GitLabToken: "server-token"}, + wantToken: "server-token", + }, + { + name: "gateway server uses the gateway credentials and API base URL override", + gatewayMode: true, + objects: []client.Object{ + &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, + Spec: kelos.WebhookGatewaySpec{GitLab: &kelos.GitLabGateway{ + SecretRef: kelos.SecretReference{Name: "webhook-secret"}, + APIBaseURL: server.URL, + CredentialsRef: &kelos.SecretReference{Name: "gitlab-credentials"}, + }}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gitlab-credentials", Namespace: "default"}, + Data: map[string][]byte{"GITLAB_TOKEN": []byte("gateway-token")}, + }, + }, + annotations: map[string]string{ + reporting.AnnotationSourceBaseURL: "https://gitlab.external.example", + reporting.AnnotationWebhookGateway: "gl", + }, + config: reportingConfig{GatewayMode: true}, + wantToken: "gateway-token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPath, gotToken = "", "" + annotations := map[string]string{ + reporting.AnnotationGitHubReporting: "enabled", + reporting.AnnotationGitHubCommentMode: string(kelos.GitHubCommentModePerTask), + reporting.AnnotationSourceProvider: reporting.SourceProviderGitLab, + reporting.AnnotationSourceKind: reporting.SourceKindMergeRequest, + reporting.AnnotationSourceNumber: "7", + reporting.AnnotationSourceRepo: "group/sub/repo", + } + for k, v := range tt.annotations { + annotations[k] = v + } + task := &kelos.Task{ + ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", Annotations: annotations}, + Status: kelos.TaskStatus{Phase: kelos.TaskPhasePending}, + } + objects := append([]client.Object{task}, tt.objects...) + reconciler := &reportingReconciler{ + Client: fake.NewClientBuilder().WithScheme(newReportingTestScheme(t)).WithObjects(objects...).Build(), + config: tt.config, + cache: reporting.NewReportStateCache(), + } + + if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: "default", Name: "task"}, + }); err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if gotPath != "/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/notes" { + t.Errorf("note posted to %q", gotPath) + } + if gotToken != tt.wantToken { + t.Errorf("PRIVATE-TOKEN = %q, want %q", gotToken, tt.wantToken) + } + + var updated kelos.Task + if err := reconciler.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "task"}, &updated); err != nil { + t.Fatal(err) + } + if updated.Annotations[reporting.AnnotationGitHubCommentID] != "77" { + t.Errorf("expected note id persisted, got %q", updated.Annotations[reporting.AnnotationGitHubCommentID]) + } + }) + } +} + +func TestResolveGitLabReportingCredsErrors(t *testing.T) { + gateway := &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, + Spec: kelos.WebhookGatewaySpec{GitLab: &kelos.GitLabGateway{SecretRef: kelos.SecretReference{Name: "webhook-secret"}}}, + } + reconciler := &reportingReconciler{Client: fake.NewClientBuilder().WithScheme(newReportingTestScheme(t)).WithObjects(gateway).Build()} + + noToken := &kelos.Task{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Annotations: map[string]string{}}} + if _, _, err := reconciler.resolveGitLabReportingCreds(context.Background(), noToken); err == nil || !strings.Contains(err.Error(), "no GitLab token") { + t.Errorf("expected missing server token error, got %v", err) + } + + noCreds := &kelos.Task{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Annotations: map[string]string{reporting.AnnotationWebhookGateway: "gl"}}} + if _, _, err := reconciler.resolveGitLabReportingCreds(context.Background(), noCreds); err == nil || !strings.Contains(err.Error(), "credentialsRef") { + t.Errorf("expected missing credentialsRef error, got %v", err) + } +} + +func TestResolveGitLabReportingCredsRejectsGitHubTokenKey(t *testing.T) { + gateway := &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, + Spec: kelos.WebhookGatewaySpec{GitLab: &kelos.GitLabGateway{ + SecretRef: kelos.SecretReference{Name: "webhook-secret"}, + CredentialsRef: &kelos.SecretReference{Name: "gitlab-credentials"}, + }}, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gitlab-credentials", Namespace: "default"}, + Data: map[string][]byte{"GITHUB_TOKEN": []byte("not-a-gitlab-key")}, + } + reconciler := &reportingReconciler{Client: fake.NewClientBuilder().WithScheme(newReportingTestScheme(t)).WithObjects(gateway, secret).Build()} + + task := &kelos.Task{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Annotations: map[string]string{reporting.AnnotationWebhookGateway: "gl"}}} + _, _, err := reconciler.resolveGitLabReportingCreds(context.Background(), task) + if err == nil || !strings.Contains(err.Error(), "no GITLAB_TOKEN") { + t.Fatalf("expected GITLAB_TOKEN-only credentials error, got %v", err) + } +} + func TestReportingReconcilerUsesGatewayGitHubAppIdentityForStickyComments(t *testing.T) { var userRequests atomic.Int32 var tokenRequests atomic.Int32 diff --git a/codex/Dockerfile b/codex/Dockerfile index d2f6c0991..fd82a553e 100644 --- a/codex/Dockerfile +++ b/codex/Dockerfile @@ -27,6 +27,15 @@ RUN ARCH=$(dpkg --print-architecture) \ && tar -C /usr/local -xzf "/tmp/${TARBALL}" \ && rm "/tmp/${TARBALL}" "/tmp/${TARBALL}.sha256" +ARG GLAB_VERSION=1.116.0 +RUN ARCH=$(dpkg --print-architecture) \ + && DEB="glab_${GLAB_VERSION}_linux_${ARCH}.deb" \ + && curl -fsSL -o "/tmp/${DEB}" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${DEB}" \ + && curl -fsSL -o /tmp/glab-checksums.txt "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/checksums.txt" \ + && (cd /tmp && sha256sum --ignore-missing -c glab-checksums.txt) \ + && dpkg -i "/tmp/${DEB}" \ + && rm "/tmp/${DEB}" /tmp/glab-checksums.txt + ENV PATH="/usr/local/go/bin:${PATH}" ARG CODEX_VERSION=0.152.1 @@ -38,6 +47,9 @@ COPY codex/kelos_entrypoint.sh /kelos_entrypoint.sh COPY hack/agent-gh-wrapper.sh /usr/local/bin/gh RUN chmod +x /usr/local/bin/gh +COPY hack/agent-glab-wrapper.sh /usr/local/bin/glab +RUN chmod +x /usr/local/bin/glab + ARG TARGETARCH COPY bin/kelos-capture-linux-${TARGETARCH} /kelos/kelos-capture COPY bin/kelos-codex-auth-refresh-linux-${TARGETARCH} /kelos/kelos-codex-auth-refresh diff --git a/cursor/Dockerfile b/cursor/Dockerfile index 5e030fc23..c90b3b79d 100644 --- a/cursor/Dockerfile +++ b/cursor/Dockerfile @@ -26,6 +26,15 @@ RUN ARCH=$(dpkg --print-architecture) \ && tar -C /usr/local -xzf "/tmp/${TARBALL}" \ && rm "/tmp/${TARBALL}" "/tmp/${TARBALL}.sha256" +ARG GLAB_VERSION=1.116.0 +RUN ARCH=$(dpkg --print-architecture) \ + && DEB="glab_${GLAB_VERSION}_linux_${ARCH}.deb" \ + && curl -fsSL -o "/tmp/${DEB}" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${DEB}" \ + && curl -fsSL -o /tmp/glab-checksums.txt "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/checksums.txt" \ + && (cd /tmp && sha256sum --ignore-missing -c glab-checksums.txt) \ + && dpkg -i "/tmp/${DEB}" \ + && rm "/tmp/${DEB}" /tmp/glab-checksums.txt + ENV PATH="/usr/local/go/bin:${PATH}" COPY cursor/kelos_entrypoint.sh /kelos_entrypoint.sh @@ -34,6 +43,9 @@ RUN chmod +x /kelos_entrypoint.sh COPY hack/agent-gh-wrapper.sh /usr/local/bin/gh RUN chmod +x /usr/local/bin/gh +COPY hack/agent-glab-wrapper.sh /usr/local/bin/glab +RUN chmod +x /usr/local/bin/glab + ARG TARGETARCH COPY bin/kelos-capture-linux-${TARGETARCH} /kelos/kelos-capture diff --git a/docs/agent-image-interface.md b/docs/agent-image-interface.md index 99ad3f443..b79ffd5f4 100644 --- a/docs/agent-image-interface.md +++ b/docs/agent-image-interface.md @@ -79,11 +79,16 @@ Kelos sets the following reserved environment variables on agent containers: | `OPENCODE_API_KEY` | API key for OpenCode (`opencode` agent, api-key or oauth credential type). The OpenCode entrypoint maps this for supported provider prefixes, including `ZHIPU_API_KEY` for `zai/*` models. | When agent type is `opencode` | | `CURSOR_API_KEY` | API key for Cursor CLI (`cursor` agent, api-key or oauth credential type) | When agent type is `cursor` | | `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token (`claude-code` agent, oauth credential type) | When credential type is `oauth` and agent type is `claude-code` | -| `GITHUB_TOKEN` | GitHub token for workspace access. **Captured at pod start and not refreshed in-process — custom images should read `KELOS_GITHUB_TOKEN_FILE` instead (see [GitHub token freshness](#github-token-freshness)).** | When workspace has a `secretRef` | -| `GH_TOKEN` | GitHub token for `gh` CLI (github.com). Same freshness caveat as `GITHUB_TOKEN`; the bundled `gh` wrapper in reference images overrides it from the token file on each call. | When workspace has a `secretRef` and repo is on github.com | -| `GH_ENTERPRISE_TOKEN` | GitHub token for `gh` CLI (GitHub Enterprise). Same freshness caveat as `GH_TOKEN`. | When workspace has a `secretRef` and repo is on a GitHub Enterprise host | -| `GH_HOST` | Hostname for GitHub Enterprise | When repo is on a GitHub Enterprise host | -| `KELOS_GITHUB_TOKEN_FILE` | Path to a file containing the current GitHub token. The file is kubelet-synced from the underlying Secret, so re-reading it on each GitHub call picks up refreshed installation tokens without a pod restart. **Recommended source of truth for custom agent images.** | When workspace has a `secretRef` | +| `GITHUB_TOKEN` | GitHub token for workspace access. **Captured at pod start and not refreshed in-process — custom images should read `KELOS_GITHUB_TOKEN_FILE` instead (see [GitHub token freshness](#github-token-freshness)).** | When a `github` workspace has a `secretRef` | +| `GH_TOKEN` | GitHub token for `gh` CLI (github.com). Same freshness caveat as `GITHUB_TOKEN`; the bundled `gh` wrapper in reference images overrides it from the token file on each call. | When a `github` workspace has a `secretRef` and repo is on github.com | +| `GH_ENTERPRISE_TOKEN` | GitHub token for `gh` CLI (GitHub Enterprise). Same freshness caveat as `GH_TOKEN`. | When a `github` workspace has a `secretRef` and repo is on a GitHub Enterprise host | +| `GH_HOST` | Hostname for GitHub Enterprise | When a `github` workspace repo is on a GitHub Enterprise host | +| `GH_CONFIG_DIR` | Clean `gh` configuration directory on the workspace volume | When a `github` workspace has a `secretRef` | +| `KELOS_GITHUB_TOKEN_FILE` | Path to a file containing the current GitHub token. The file is kubelet-synced from the underlying Secret, so re-reading it on each GitHub call picks up refreshed installation tokens without a pod restart. **Recommended source of truth for custom agent images.** | When a `github` workspace has a `secretRef` | +| `GITLAB_TOKEN` | GitLab access token for workspace access and the `glab` CLI. Captured at pod start; the bundled `glab` wrapper in reference images re-reads `KELOS_GITLAB_TOKEN_FILE` on each call. | When a `gitlab` workspace has a `secretRef` | +| `GITLAB_HOST` | GitLab instance URL (scheme, host, and port) derived from the workspace repo, so `glab` targets self-hosted and in-cluster instances | When the workspace provider is `gitlab` | +| `GLAB_CONFIG_DIR`, `GLAB_NO_PROMPT`, `GLAB_CHECK_UPDATE`, `GLAB_SEND_TELEMETRY` | Clean `glab` configuration directory on the workspace volume, prompts disabled, update checks and telemetry off | When a `gitlab` workspace has a `secretRef` | +| `KELOS_GITLAB_TOKEN_FILE` | Path to a kubelet-synced file containing the current GitLab token. **Recommended source of truth for custom agent images.** | When a `gitlab` workspace has a `secretRef` | | `KELOS_AGENT_TYPE` | The agent type (`claude-code`, `codex`, `gemini`, `opencode`, `cursor`) | Always | | `KELOS_TASK_NAME` | The name of the Task being run, so an image can correlate its run with the Task that launched it (progress streaming, steering, cancellation against an external control plane). Set by the worker-runner on each Task a pooled worker executes. The worker pod is long-lived and serves many Tasks, so read this at agent start rather than caching it per pod. Job-backed Tasks can supply the same information themselves through `podOverrides.env`, which pooled Tasks cannot use. | Worker pool Tasks | | `KELOS_BASE_BRANCH` | The base branch (workspace `ref`) for the task | When workspace has a non-empty `ref` | @@ -198,6 +203,15 @@ Two concrete recommendations: already reads the file on each invocation, with the `$GITHUB_TOKEN` env var as a fallback for images that have not adopted the file. +GitLab workspaces (`Workspace.spec.provider: gitlab`) follow the same +pattern with `$KELOS_GITLAB_TOKEN_FILE`, `$GITLAB_TOKEN`, and the `glab` +CLI. The reference images install +[`hack/agent-glab-wrapper.sh`](../hack/agent-glab-wrapper.sh) at +`/usr/local/bin/glab`, which exports `GITLAB_TOKEN` from the file before +each invocation and, on first use, registers the `GITLAB_HOST` instance in +`GLAB_CONFIG_DIR` so API and git calls use the host's scheme and port +(plain-http and non-standard-port instances work without `glab auth login`). + ## Output Capture The entrypoint should pipe the agent's stdout into `/kelos/kelos-capture`, diff --git a/docs/integration.md b/docs/integration.md index 994dde72b..7b5fa7bba 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -308,6 +308,70 @@ Then configure a webhook in Linear (Settings → API → Webhooks) pointing to ` **Linear-specific variables:** `{{.Type}}` (resource type), `{{.State}}` (workflow state), `{{.Action}}` (webhook action), `{{.IssueID}}` (parent issue ID for Comment events), `{{.Labels}}`, `{{.Payload}}` (full payload access). +### GitLab Webhooks + +React to GitLab events in real time from gitlab.com, a self-hosted instance, or a GitLab running inside the cluster: merge requests, issues, notes (comments), pipelines, and pushes. The webhook server authenticates deliveries by comparing the `X-Gitlab-Token` header with the configured secret. + +```yaml +apiVersion: kelos.dev/v1alpha2 +kind: TaskSpawner +metadata: + name: gitlab-responder +spec: + when: + gitlabWebhook: + events: + - note + - pipeline + project: group/repo + filters: + # Someone typed "/kelos fix" on a merge request. + - event: note + noteOn: MergeRequest + bodyPattern: '^/kelos fix' + # A pipeline on a merge request failed. + - event: pipeline + status: failed + taskTemplate: + type: claude-code + workspaceRef: + name: my-gitlab-workspace + credentials: + type: oauth + secretRef: + name: claude-oauth-token + branch: "{{.Branch}}" + promptTemplate: | + GitLab {{.Event}} on {{.Kind}} !{{.Number}}: {{.Title}} + {{.URL}} + {{- if .CommentBody}} + + Comment: {{.CommentBody}} + {{- end}} + {{- if .PipelineStatus}} + + Pipeline {{.PipelineStatus}}: {{.PipelineURL}} + {{- end}} + maxConcurrency: 3 +``` + +**Workspace:** `my-gitlab-workspace` must set `spec.provider: gitlab` and reference a Secret with a `GITLAB_TOKEN` key; the controller marks the TaskSpawner `Failed` otherwise. See [Workspace Authentication](reference.md#workspace-authentication). + +**Setup:** Enable the GitLab webhook server in your Helm values (`webhookServer.sources.gitlab.enabled: true`) and create the secret token: + +```bash +kubectl create secret generic gitlab-webhook-secret \ + --from-literal=WEBHOOK_SECRET=your-gitlab-webhook-token +``` + +Then add a project or group webhook in GitLab (Settings → Webhooks) pointing to `https://your-webhook-domain/webhook/gitlab`, paste the same value in **Secret token**, and enable the triggers you listed in `events` (Issues, Merge request, Comments, Pipeline, Push). For an in-cluster GitLab the URL can be the webhook Service, e.g. `http://kelos-webhook-gitlab.kelos-system.svc:8443/`; allow requests to the local network in GitLab's outbound request settings. Per-tenant secrets and multiple GitLab instances go through a [WebhookGateway](reference.md#webhookgateway) with `spec.gitlab`. + +**Filtering options:** `events` (required — `issue`, `merge_request`, `note`, `pipeline`, `push`, `tag_push`), `project`, `excludeAuthors`, and per-filter fields: `action`, `labels`, `excludeLabels`, `state`, `branch`, `status` (pipelines), `noteOn`, `bodyPattern`, `excludeBodyPatterns`, `draft`, `author`, `excludeAuthors`. + +**GitLab-specific variables:** `{{.Event}}`, `{{.Action}}`, `{{.Sender}}`, `{{.Repository}}`, `{{.Kind}}` (`Issue`, `MR`, or `webhook`), `{{.Number}}`, `{{.Branch}}`, `{{.State}}`, `{{.Labels}}`, `{{.HeadSHA}}`, `{{.NoteOn}}`, `{{.CommentBody}}`, `{{.CommentURL}}`, `{{.PipelineStatus}}`, `{{.PipelineURL}}`, `{{.Payload}}`. Notes and pipelines attached to a merge request carry that merge request's `{{.ID}}` (`mr-`), `{{.Number}}`, and `{{.Branch}}`. + +**Status notes:** set `reporting.comments.mode` (`PerTask` or `Sticky`) on the spawner to post Task status notes on the originating issue or merge request. The per-source server reads its token from `webhookServer.sources.gitlab.tokenSecretName` (a Secret with a `GITLAB_TOKEN` key); gateway-bound spawners use the gateway's `spec.gitlab.credentialsRef` and optional `spec.gitlab.apiBaseURL`. + ### Generic Webhooks React to arbitrary HTTP POST events from any system that can deliver a JSON payload — Sentry, Notion, Slack, Drata, PagerDuty, internal services, or anything else. Unlike the GitHub and Linear webhook sources, the generic webhook source has no built-in knowledge of any particular schema; you describe how to extract fields and what to filter on using JSONPath expressions. diff --git a/docs/reference.md b/docs/reference.md index 69d863f4a..f87ee04df 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -624,8 +624,9 @@ keep repository access without restarting (see [Workspace authentication](#works |-------|-------------|----------| | `spec.repo` | Git repository URL to clone (HTTPS, git://, or SSH) | Yes | | `spec.ref` | Branch, tag, or commit SHA to checkout (defaults to repo's default branch) | No | -| `spec.secretRef.name` | Secret containing credentials for git auth and `gh` CLI (see [authentication methods](#workspace-authentication) below) | No | -| `spec.ghproxy` | Enables the workspace-scoped ghproxy when set to `{}`; omitted or `null` disables it | No | +| `spec.provider` | Git hosting provider: `github` (default) or `gitlab`. Selects the Secret key read from `spec.secretRef` (`GITHUB_TOKEN` or `GITLAB_TOKEN`), the credentials exported to agent containers, and which CLI (`gh` or `glab`) is preconfigured. A TaskSpawner whose source does not match the provider of its Workspace is marked `Failed` (see [authentication methods](#workspace-authentication) below) | No | +| `spec.secretRef.name` | Secret containing the provider's token for git auth and the provider CLI (see [authentication methods](#workspace-authentication) below) | No | +| `spec.ghproxy` | Enables the workspace-scoped ghproxy when set to `{}`; omitted or `null` disables it. Only valid with `provider: github` (CEL-enforced) | No | | `spec.remotes[].name` | Git remote name to add after cloning (must not be `"origin"`) | Yes (per remote) | | `spec.remotes[].url` | Git remote URL | Yes (per remote) | | `spec.files[].path` | Relative file path inside the repository (e.g., `CLAUDE.md`) | Yes (per file) | @@ -658,7 +659,7 @@ Notes: ### Workspace Authentication -The workspace secret referenced by `spec.secretRef.name` supports two authentication methods: +The workspace secret referenced by `spec.secretRef.name` is interpreted according to `spec.provider`. For the default `github` provider it supports two authentication methods: **Personal Access Token (PAT):** @@ -687,6 +688,34 @@ Kelos preserves a username included in the repository URL. When the URL omits the username, Kelos uses `x-access-token`, which is compatible with GitHub PATs and GitHub App installation tokens. +**GitLab (`provider: gitlab`):** + +Set `spec.provider: gitlab` and store a GitLab personal, group, or project +access token with the `api` scope under the `GITLAB_TOKEN` key: + +```bash +kubectl create secret generic gitlab-token \ + --from-literal=GITLAB_TOKEN= +``` + +```yaml +spec: + repo: http://gitlab-webservice-default.gitlab.svc:8181/group/repo.git + provider: gitlab + secretRef: + name: gitlab-token +``` + +Git authenticates with username `oauth2` and the token as password. Agent +containers receive `GITLAB_TOKEN`, `GITLAB_HOST` (the instance URL derived +from `spec.repo`, so self-hosted and in-cluster instances work unchanged), +`KELOS_GITLAB_TOKEN_FILE`, and a `glab` CLI preconfigured through +`GLAB_CONFIG_DIR` and `GLAB_NO_PROMPT`; no `gh` variables are set. The +`gitlab` and `gitlabWebhook` TaskSpawner sources require a `gitlab` +Workspace and use the same token for API calls. A Secret that lacks the +`GITLAB_TOKEN` key fails the Task or TaskSpawner immediately; a `GITHUB_TOKEN` +key is never used for a GitLab workspace. + **GitHub App (recommended for production/org use):** The secret contains three keys. Kelos exchanges them for a short-lived @@ -742,7 +771,7 @@ to receive refreshed credentials during long-running work. | Field | Description | Required | |-------|-------------|----------| -| `spec.taskTemplate.workspaceRef.name` | Workspace resource (repo URL, auth, and clone target for spawned Tasks) | Yes (when using `githubIssues`, `githubPullRequests`, `githubWebhook`, `linearWebhook`, or `webhook`) | +| `spec.taskTemplate.workspaceRef.name` | Workspace resource (repo URL, auth, and clone target for spawned Tasks) | Yes (when using `githubIssues`, `githubPullRequests`, `githubWebhook`, `linearWebhook`, `gitlab`, `gitlabWebhook`, or `webhook`) | | `spec.when.githubIssues.repo` | Override repository to poll for issues (in `owner/repo` format or full URL); defaults to workspace repo URL | No | | `spec.when.githubIssues.labels` | Filter issues by labels | No | | `spec.when.githubIssues.excludeLabels` | Exclude issues with these labels | No | @@ -812,6 +841,24 @@ to receive refreshed credentials during long-running work. | `spec.when.linearWebhook.filters[].labels` | Require the issue to have all of these labels | No | | `spec.when.linearWebhook.filters[].excludeLabels` | Exclude issues with any of these labels | No | | `spec.when.linearWebhook.gatewayRef.name` | Bind this source to a [WebhookGateway](#webhookgateway) in the same namespace whose `spec.linear` field is set. The per-source webhook server ignores this spawner when the reference is present | No | +| `spec.when.gitlabWebhook.events` | GitLab event kinds to listen for, matching the payload `object_kind`: `issue`, `merge_request`, `note`, `pipeline`, `push`, `tag_push` | Yes (when using gitlabWebhook) | +| `spec.when.gitlabWebhook.project` | Restrict deliveries to one project by full path (`group/subgroup/project`); if empty, events from any project are accepted | No | +| `spec.when.gitlabWebhook.excludeAuthors` | Exclude events triggered by any of these GitLab usernames; applied before filter evaluation | No | +| `spec.when.gitlabWebhook.filters[].event` | GitLab event kind this filter applies to. An event kind with no filter of its own is accepted as listed in `events` | Yes (per filter) | +| `spec.when.gitlabWebhook.filters[].action` | Filter `issue` and `merge_request` events by payload action (e.g., `open`, `update`, `close`, `reopen`, `approved`, `unapproved`, `merge`) | No | +| `spec.when.gitlabWebhook.filters[].labels` | Require the issue or merge request to have all of these labels. For `note` events the commented item's labels are used | No | +| `spec.when.gitlabWebhook.filters[].excludeLabels` | Exclude issues or merge requests with any of these labels | No | +| `spec.when.gitlabWebhook.filters[].state` | Filter `issue` and `merge_request` events by state (`opened`, `closed`, `merged`, `locked`) | No | +| `spec.when.gitlabWebhook.filters[].branch` | Filter `merge_request` events by source branch and `push`/`pipeline` events by branch (exact match or glob) | No | +| `spec.when.gitlabWebhook.filters[].status` | Filter `pipeline` events by status (e.g., `success`, `failed`, `canceled`, `running`, `pending`) | No | +| `spec.when.gitlabWebhook.filters[].noteOn` | Scope `note` events to comments on `Issue`, `MergeRequest`, `Commit`, or `Snippet`. Omit to match any subject | No | +| `spec.when.gitlabWebhook.filters[].bodyPattern` | Require the note body to match a Go re2 regular expression | No | +| `spec.when.gitlabWebhook.filters[].excludeBodyPatterns` | Exclude `note` events whose body matches any of these Go re2 regular expressions (OR semantics) | No | +| `spec.when.gitlabWebhook.filters[].draft` | Filter `merge_request` events by draft status | No | +| `spec.when.gitlabWebhook.filters[].author` | Filter by the username of the user who triggered the event | No | +| `spec.when.gitlabWebhook.filters[].excludeAuthors` | Exclude events triggered by any of these usernames | No | +| `spec.when.gitlabWebhook.reporting.comments.mode` | Enables status notes on the originating GitLab issue or merge request (`issue`, `merge_request`, `note`, and merge-request `pipeline` events). `PerTask` (default) creates one note for each Task; `Sticky` maintains one note per TaskSpawner and item across Tasks. Requires a GitLab token: `webhookServer.sources.gitlab.tokenSecretName` on the per-source server, or `spec.gitlab.credentialsRef` on the bound [WebhookGateway](#webhookgateway) | No | +| `spec.when.gitlabWebhook.gatewayRef.name` | Bind this source to a [WebhookGateway](#webhookgateway) in the same namespace whose `spec.gitlab` field is set. The per-source webhook server ignores this spawner when the reference is present | No | | `spec.when.slack.channels` | Restrict which Slack channels the bot listens in (channel IDs like `"C0123456789"`); when empty, listens in all invited channels | No | | `spec.when.slack.botMessagePolicy` | Controls whether bot-originated messages can trigger this spawner: `None` (default) rejects all bot messages, `All` allows all including self, `OthersOnly` allows other bots but rejects the bot's own output to prevent self-trigger loops | No | | `spec.when.slack.triggers[].pattern` | RE2 regex matched against message text (unanchored); leading `<@USER_ID>` mentions are stripped before matching; bot mention required unless `mentionOptional` is set; multiple triggers use OR semantics; when empty, every bot mention fires | No | @@ -827,6 +874,19 @@ to receive refreshed credentials during long-running work. | `spec.when.webhook.excludeFilters[].pattern` | Exclude the delivery on a regex match against the extracted field value (mutually exclusive with `value`) | Conditional | | `spec.when.webhook.gatewayRef.name` | Bind this source to a [WebhookGateway](#webhookgateway) in the same namespace whose `spec.generic` field is set. Generic gateway deliveries remain unauthenticated, and the per-source server ignores this spawner when the reference is present | No | | `spec.when.jira.pollInterval` | Per-source poll interval (e.g., `"30s"`, `"5m"`). Defaults to `5m` when omitted | No | +| `spec.when.gitlab.baseUrl` | GitLab instance URL for API calls (e.g., `https://gitlab.example.com` or an in-cluster service URL). Defaults to the scheme and host of the workspace repo URL | No | +| `spec.when.gitlab.project` | Full project path to poll (`group/subgroup/project`). Defaults to the path of the workspace repo URL | No | +| `spec.when.gitlab.types` | Item types to discover: `issues`, `mergeRequests`, or both (default: `issues`). Merge request work items get an `mr-` ID prefix so they never collide with issues of the same number | No | +| `spec.when.gitlab.labels` | Filter items by labels; an item must carry all of them | No | +| `spec.when.gitlab.excludeLabels` | Exclude items with any of these labels (client-side) | No | +| `spec.when.gitlab.state` | Filter by state: `opened`, `closed`, `all` (default: `opened`) | No | +| `spec.when.gitlab.reviewState` | Filter merge requests by review outcome: `approved` (has the required approvals), `changes_requested` (a reviewer requested changes; wins over approvals), `any` (default). Issues are unaffected | No | +| `spec.when.gitlab.pipelineStatus` | Filter merge requests by head pipeline status: `success`, `failed`, `running`, `pending`, `canceled`, `any` (default). A newer pipeline finishing in the selected status retriggers a completed Task. Issues are unaffected | No | +| `spec.when.gitlab.commentPolicy.triggerComment` | Requires a matching command in the item description or a note to include the item. A newer trigger note retriggers a completed Task | No | +| `spec.when.gitlab.commentPolicy.excludeComments` | Blocks items whose most recent matching command is an exclude comment | No | +| `spec.when.gitlab.commentPolicy.allowedUsers` | Restrict comment control to specific GitLab usernames. When empty, any user's command is honored | No | +| `spec.when.gitlab.reporting.comments.mode` | Enables status notes on the originating GitLab issue or merge request. `PerTask` (default) creates one note for each Task; `Sticky` maintains one note per TaskSpawner and item across Tasks | No | +| `spec.when.gitlab.pollInterval` | Per-source poll interval (e.g., `"30s"`, `"5m"`). Defaults to `5m` when omitted | No | | `spec.when.cron.schedule` | Cron schedule expression (e.g., `"0 * * * *"`) | Yes (when using cron) | | `spec.credentials[].name` | Unique name for a credential distributed by this TaskSpawner. The name is recorded in the `kelos.dev/spawner-credential` label on generated Tasks | Yes when `spec.credentials` is set | | `spec.credentials[].type` | Credential type (`api-key` or `oauth`) | Yes when `spec.credentials` is set | @@ -893,7 +953,7 @@ spec: ### Generated Task Names -For `githubIssues`, `githubPullRequests`, `jira`, and `cron` sources, Kelos first +For `githubIssues`, `githubPullRequests`, `jira`, `gitlab`, and `cron` sources, Kelos first lowercases the work item ID when forming the Task name: `-`. @@ -1003,6 +1063,10 @@ The `promptTemplate` field uses Go `text/template` syntax. Available variables d | `{{.Time}}` | Trigger time (RFC3339) | Empty | Empty | Empty | Empty | Empty | Empty | Cron tick time (e.g., `"2026-02-07T09:00:00Z"`) | | `{{.Schedule}}` | Cron schedule expression | Empty | Empty | Empty | Empty | Empty | Empty | Schedule string (e.g., `"0 * * * *"`) | +> **GitLab webhook:** the `gitlabWebhook` source exposes `{{.Event}}` (`object_kind`), `{{.Action}}`, `{{.Sender}}`, `{{.Repository}}` (project path), `{{.RepositoryURL}}`, `{{.ID}}`, `{{.Number}}`, `{{.Title}}`, `{{.Body}}`, `{{.URL}}`, `{{.Kind}}` (`"Issue"`, `"MR"`, or `"webhook"`), `{{.Branch}}`, `{{.Ref}}`, `{{.State}}`, `{{.Labels}}`, `{{.HeadSHA}}`, `{{.NoteOn}}`, `{{.CommentBody}}`, `{{.CommentURL}}` (`note` events), `{{.PipelineStatus}}`, `{{.PipelineURL}}` (`pipeline` events), and `{{.Payload}}`. Every variable is always defined; fields that do not apply to the event are empty. Notes and pipelines attached to a merge request carry that merge request's identity (`{{.ID}}` is `mr-`, `{{.Branch}}` is its source branch). + +> **GitLab:** the `gitlab` source exposes the same variables as GitHub Issues: `{{.ID}}` (issue IID as a string, or `mr-` for merge requests), `{{.Number}}` (IID), `{{.Title}}`, `{{.Body}}` (description), `{{.URL}}`, `{{.Labels}}`, `{{.Comments}}` (non-system discussion notes; inline diff notes are excluded), and `{{.Kind}}` (`"Issue"` or `"MR"`). Merge requests additionally set `{{.Branch}}` (source branch), `{{.PipelineStatus}}` and `{{.PipelineURL}}` (head pipeline), `{{.ReviewComments}}` (inline diff notes as `path:line` blocks), and `{{.ReviewState}}` (`approved`, `changes_requested`, or empty; populated only when `reviewState` is set). + > **Generic Webhook only:** any additional keys declared in `spec.when.webhook.fieldMapping` are also exposed as top-level template variables (e.g., `fieldMapping: {severity: "$.level"}` makes `{{.severity}}` available). > **`{{.ChangedFiles}}` and `filePatterns`:** For pull request webhook events, the changed-file list is fetched lazily and only when a filter's `filePatterns` needs it to decide a match. As a result, `{{.ChangedFiles}}` is populated for PR events **only when the matching filter declares `filePatterns`**; without it, `{{.ChangedFiles}}` renders as an empty list. Push events populate `{{.ChangedFiles}}` from the payload regardless. @@ -1095,23 +1159,26 @@ Example — fetch a GitHub API resource authenticated with a GitHub App installa A `WebhookGateway` is a per-channel authentication and routing boundary for webhook-driven TaskSpawners and SessionSpawners. It owns one inbound path, `/webhook//` (surfaced in `status.path`), verifies inbound -deliveries against its own secret (github/linear), and fans out only to +deliveries against its own secret (github/linear/gitlab), and fans out only to spawners in its own namespace that reference it via `gatewayRef`. This enables per-tenant secrets and multiple GitHub instances (github.com plus GitHub Enterprise) without a per-instance Deployment. Enable the gateway server with `webhookServer.gatewayServer.enabled` in the Helm chart. See [example 18](../examples/18-webhookgateway). -Exactly one provider sub-struct (`spec.github`, `spec.linear`, or `spec.generic`) -must be set; the one that is present selects the source. +Exactly one provider sub-struct (`spec.github`, `spec.linear`, `spec.gitlab`, or +`spec.generic`) must be set; the one that is present selects the source. | Field | Description | Required | | --- | --- | --- | -| `spec.github` | GitHub gateway configuration (see below). Set exactly one of `github`/`linear`/`generic` | Conditional | +| `spec.github` | GitHub gateway configuration (see below). Set exactly one of `github`/`linear`/`gitlab`/`generic` | Conditional | | `spec.github.secretRef.name` | Secret holding the inbound HMAC secret (under a `webhook-secret` key) | Yes (for github) | | `spec.github.apiBaseURL` | GitHub API base URL for outbound calls (PR-file enrichment, status reporting, and GitHub App token minting), e.g. `https://ghe.example.com/api/v3`. Defaults to `https://api.github.com` | No | | `spec.github.credentialsRef.name` | Secret holding outbound GitHub API credentials — a `GITHUB_TOKEN` key (PAT) or GitHub App keys (`appID`, `installationID`, `privateKey`) | No | | `spec.linear.secretRef.name` | Secret holding the inbound HMAC secret (under a `webhook-secret` key) | Yes (for linear) | +| `spec.gitlab.secretRef.name` | Secret holding the GitLab webhook secret token (under a `webhook-secret` key). GitLab sends the token verbatim in `X-Gitlab-Token`; deliveries whose header does not equal the stored value are rejected | Yes (for gitlab) | +| `spec.gitlab.apiBaseURL` | GitLab instance URL used for status notes (e.g. `https://gitlab.example.com` or an in-cluster Service URL). Defaults to the instance URL taken from the webhook payload | No | +| `spec.gitlab.credentialsRef.name` | Secret holding a GitLab access token with the `api` scope under a `GITLAB_TOKEN` key. Required for `gitlabWebhook.reporting` on spawners bound to this gateway | No | | `spec.generic` | Generic gateway configuration (no fields yet; deliveries are accepted without verification) | Conditional | | `status.path` | Derived inbound path, `/webhook//`, relative to the configured webhook host | — | | `status.phase` | `Authenticated`, `SecretMissing`, or `Unauthenticated` (generic gateways are `Unauthenticated`) | — | @@ -1119,7 +1186,7 @@ must be set; the one that is present selects the source. > `generic` gateways are accepted but **not** signature-verified; > restrict access at the network layer. Task execution (clone/push) credentials > come from the Workspace's `secretRef`, separate from a gateway's -> `github.credentialsRef`. +> `github.credentialsRef` or `gitlab.credentialsRef`. ### "Gateway" terminology diff --git a/examples/19-taskspawner-gitlab/README.md b/examples/19-taskspawner-gitlab/README.md new file mode 100644 index 000000000..dc49ac7a2 --- /dev/null +++ b/examples/19-taskspawner-gitlab/README.md @@ -0,0 +1,116 @@ +# 19 — TaskSpawner for GitLab Issues and Merge Requests + +A TaskSpawner that polls a GitLab project (gitlab.com, self-hosted, or +in-cluster) for issues and merge requests carrying a trigger command and +creates a Task for each one. Task status is reported back as notes on the +originating issue or merge request. + +## Use Case + +Hand work to an agent straight from GitLab: label an issue or comment +`/kelos fix` on an issue or merge request, and the agent clones the repo, +does the work, and pushes a branch. Kelos posts a note when the Task is +accepted and again when it succeeds or fails. + +## Resources + +| File | Kind | Purpose | +|------|------|---------| +| `credentials-secret.yaml` | Secret | Claude OAuth token for the agent | +| `gitlab-token-secret.yaml` | Secret | GitLab access token for cloning, API polling, and notes | +| `workspace.yaml` | Workspace | GitLab repository to clone into each Task (`provider: gitlab`) | +| `taskspawner.yaml` | TaskSpawner | Watches GitLab issues and merge requests and spawns Tasks | +| `taskspawner-ci-remediation.yaml` | TaskSpawner | Spawns a Task for every merge request whose head pipeline failed | +| `taskspawner-webhook.yaml` | TaskSpawner | Real-time variant driven by GitLab webhooks (`/kelos fix` notes and failed pipelines) | +| `gitlab-webhook-secret.yaml` | Secret | Webhook secret token for the GitLab webhook server (webhook variant only) | + +## How It Works + +``` +TaskSpawner polls GitLab issues + merge requests (label: kelos, state: opened) + │ + ├── item with /kelos fix note → creates Task → posts "accepted" note + │ └── agent pushes fix → posts "succeeded" note + └── newer /kelos fix note on a finished item → retriggers a Task +``` + +The GitLab instance URL and project path are derived from the Workspace +`spec.repo`. Set `spec.when.gitlab.baseUrl` when the API must be reached on a +different address than the clone URL (for example an in-cluster Service), and +`spec.when.gitlab.project` to poll a different project than the one cloned. + +## Steps + +1. **Create a GitLab access token** (personal, group, or project token) with + the `api` scope so it can clone, read issues and merge requests, and post + notes. + +2. **Edit the secrets** — replace placeholders in both secret files. The GitLab + token goes under the `GITLAB_TOKEN` key. + +3. **Edit `workspace.yaml`** — set your GitLab repository URL and branch and + keep `provider: gitlab`, which tells Kelos to read `GITLAB_TOKEN`, + authenticate git as `oauth2`, and preconfigure `glab` for the agent. For an + in-cluster GitLab, use the Service URL, e.g. + `http://gitlab-webservice-default.gitlab.svc:8181/group/repo.git`. + +4. **Apply the resources:** + +```bash +kubectl apply -f examples/19-taskspawner-gitlab/ +``` + +5. **Verify the spawner is running:** + +```bash +kubectl get taskspawners -w +``` + +6. **Trigger a Task** by adding the `kelos` label to an issue or merge request + and commenting `/kelos fix` on it. The TaskSpawner picks it up on the next + poll. + +7. **Watch spawned Tasks:** + +```bash +kubectl get tasks -w +``` + +8. **Cleanup:** + +```bash +kubectl delete -f examples/19-taskspawner-gitlab/ +``` + +## Customization + +- Drop `commentPolicy` to spawn a Task for every labeled item without waiting + for a command, or set `allowedUsers` to restrict who may issue commands. +- Set `types` to `["issues"]` or `["mergeRequests"]` to watch only one kind. +- Gate merge requests on `pipelineStatus: failed` (see + `taskspawner-ci-remediation.yaml`) or `reviewState: changes_requested` to + react to CI failures and review outcomes instead of labels. +- Use `reporting.comments.mode: Sticky` to keep one status note per item + instead of one per Task. +- Give each merge request one gate. Two TaskSpawners that both match the same + merge request (for example `pipelineStatus: failed` and + `reviewState: changes_requested`) each spawn a Task that pushes to the same + source branch, so they race each other. +- Adjust `pollInterval` inside the source block to control how often GitLab is polled. + +## Webhook Variant + +`taskspawner-webhook.yaml` reacts within seconds instead of on a poll +interval. It needs the GitLab webhook server: set +`webhookServer.sources.gitlab.enabled: true` and +`webhookServer.sources.gitlab.secretName: gitlab-webhook-secret` in your Helm +values (see [`examples/helm-values-webhook.yaml`](../helm-values-webhook.yaml)), +then add a project webhook in GitLab (Settings → Webhooks) with the URL +`https:///webhook/gitlab`, the token from +`gitlab-webhook-secret.yaml`, and the **Comments** and **Pipeline events** +triggers enabled. For an in-cluster GitLab, point the webhook at the +`kelos-webhook-gitlab` Service and allow local network requests in the GitLab +admin settings (Admin → Settings → Network → Outbound requests). To get status +notes from webhook-created Tasks, also set +`webhookServer.sources.gitlab.tokenSecretName` to a Secret holding a +`GITLAB_TOKEN` with the `api` scope. diff --git a/examples/19-taskspawner-gitlab/credentials-secret.yaml b/examples/19-taskspawner-gitlab/credentials-secret.yaml new file mode 100644 index 000000000..4c8e2c4af --- /dev/null +++ b/examples/19-taskspawner-gitlab/credentials-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: claude-oauth-token +type: Opaque +stringData: + # TODO: Replace with your Claude OAuth token + CLAUDE_CODE_OAUTH_TOKEN: "REPLACE-ME" diff --git a/examples/19-taskspawner-gitlab/gitlab-token-secret.yaml b/examples/19-taskspawner-gitlab/gitlab-token-secret.yaml new file mode 100644 index 000000000..33d51df4e --- /dev/null +++ b/examples/19-taskspawner-gitlab/gitlab-token-secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gitlab-token +type: Opaque +stringData: + # TODO: Replace with a GitLab personal, group, or project access token. + # Required scope: api (clone, read issues/merge requests, post notes). + GITLAB_TOKEN: "glpat-REPLACE-ME" diff --git a/examples/19-taskspawner-gitlab/gitlab-webhook-secret.yaml b/examples/19-taskspawner-gitlab/gitlab-webhook-secret.yaml new file mode 100644 index 000000000..60d242d84 --- /dev/null +++ b/examples/19-taskspawner-gitlab/gitlab-webhook-secret.yaml @@ -0,0 +1,11 @@ +# Only needed for taskspawner-webhook.yaml. Referenced by the Helm value +# webhookServer.sources.gitlab.secretName and entered as the "Secret token" of +# the GitLab project webhook. +apiVersion: v1 +kind: Secret +metadata: + name: gitlab-webhook-secret +type: Opaque +stringData: + # TODO: Replace with a random token + WEBHOOK_SECRET: "REPLACE-ME" diff --git a/examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml b/examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml new file mode 100644 index 000000000..0b5e88d7d --- /dev/null +++ b/examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml @@ -0,0 +1,40 @@ +apiVersion: kelos.dev/v1alpha2 +kind: TaskSpawner +metadata: + name: gitlab-ci-fixer +spec: + when: + gitlab: + types: + - mergeRequests + # Only merge requests whose head pipeline failed. A newer failing pipeline + # on the same merge request retriggers a finished Task. + pipelineStatus: failed + excludeLabels: + - no-kelos + reporting: + comments: + mode: Sticky + pollInterval: 2m + taskTemplate: + type: claude-code + workspaceRef: + name: my-gitlab-workspace + credentials: + type: oauth + secretRef: + name: claude-oauth-token + branch: "{{.Branch}}" + promptTemplate: | + The CI pipeline for merge request !{{.Number}} ({{.Title}}) failed. + Pipeline: {{.PipelineURL}} + + Inspect the failure, fix the code or tests on the current branch, and push. + Do not disable or skip tests to make the pipeline green. + {{- if .ReviewComments}} + + Open review comments: + {{.ReviewComments}} + {{- end}} + ttlSecondsAfterFinished: 3600 + maxConcurrency: 2 diff --git a/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml b/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml new file mode 100644 index 000000000..19c9a443e --- /dev/null +++ b/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml @@ -0,0 +1,56 @@ +# Real-time alternative to polling: requires the GitLab webhook server +# (webhookServer.sources.gitlab.enabled in Helm) and a project webhook in GitLab +# pointing at /webhook/gitlab with the same secret token as +# gitlab-webhook-secret.yaml. +apiVersion: kelos.dev/v1alpha2 +kind: TaskSpawner +metadata: + name: gitlab-webhook-fixer +spec: + when: + gitlabWebhook: + events: + - note + - pipeline + # TODO: Replace with your project path + project: group/repo + filters: + # A "/kelos fix" comment on a merge request or issue. + - event: note + bodyPattern: '^/kelos fix' + # A merge request pipeline failed. + - event: pipeline + status: failed + # Status notes need webhookServer.sources.gitlab.tokenSecretName. + reporting: + comments: + mode: Sticky + taskTemplate: + type: claude-code + workspaceRef: + name: my-gitlab-workspace + credentials: + type: oauth + secretRef: + name: claude-oauth-token + # Merge requests (and pipelines/notes on them) set {{.Branch}} to the source + # branch; notes on issues get a fresh branch. + branch: "{{if .Branch}}{{.Branch}}{{else}}kelos-issue-{{.Number}}{{end}}" + # One Task per merge request or issue: a second delivery for the same item + # reuses the existing Task instead of creating a duplicate. + nameTemplate: "gitlab-webhook-fixer-{{.ID}}" + promptTemplate: | + GitLab {{.Event}} on {{.Kind}} #{{.Number}}: {{.Title}} + {{.URL}} + {{- if .CommentBody}} + + Comment from {{.Sender}}: + {{.CommentBody}} + {{- end}} + {{- if .PipelineStatus}} + + Pipeline {{.PipelineStatus}}: {{.PipelineURL}} + Inspect the failure, fix it on the current branch, and push. + {{- end}} + ttlSecondsAfterFinished: 3600 + maxConcurrency: 3 diff --git a/examples/19-taskspawner-gitlab/taskspawner.yaml b/examples/19-taskspawner-gitlab/taskspawner.yaml new file mode 100644 index 000000000..75ce92043 --- /dev/null +++ b/examples/19-taskspawner-gitlab/taskspawner.yaml @@ -0,0 +1,52 @@ +apiVersion: kelos.dev/v1alpha2 +kind: TaskSpawner +metadata: + name: gitlab-fixer +spec: + when: + gitlab: + # Instance URL and project path default to the Workspace repo URL. + # Uncomment to reach the API on a different address than the clone URL. + # baseUrl: http://gitlab-webservice-default.gitlab.svc:8181 + # project: group/repo + types: + - issues + - mergeRequests + labels: + - kelos + state: opened + commentPolicy: + triggerComment: "/kelos fix" + # Optional: only honor commands from these GitLab usernames + # allowedUsers: + # - alice + excludeComments: + - "/kelos stop" + reporting: + comments: + mode: PerTask + pollInterval: 2m + taskTemplate: + type: claude-code + workspaceRef: + name: my-gitlab-workspace + credentials: + type: oauth + secretRef: + name: claude-oauth-token + # Merge requests set {{.Branch}} to their source branch; issues get a fresh branch. + branch: "{{if .Branch}}{{.Branch}}{{else}}kelos-issue-{{.Number}}{{end}}" + promptTemplate: | + Work on the following GitLab {{.Kind}} and push your changes to the current branch. + + {{.Kind}} #{{.Number}}: {{.Title}} + {{.URL}} + + {{.Body}} + {{- if .Comments}} + + Discussion: + {{.Comments}} + {{- end}} + ttlSecondsAfterFinished: 3600 + maxConcurrency: 3 diff --git a/examples/19-taskspawner-gitlab/workspace.yaml b/examples/19-taskspawner-gitlab/workspace.yaml new file mode 100644 index 000000000..d64bc2131 --- /dev/null +++ b/examples/19-taskspawner-gitlab/workspace.yaml @@ -0,0 +1,14 @@ +apiVersion: kelos.dev/v1alpha2 +kind: Workspace +metadata: + name: my-gitlab-workspace +spec: + # TODO: Replace with your GitLab repository URL. For an in-cluster GitLab use + # the Service URL, e.g. http://gitlab-webservice-default.gitlab.svc:8181/group/repo.git + repo: https://gitlab.example.com/group/repo.git + ref: main + # Reads GITLAB_TOKEN from the secret, authenticates git as oauth2, and + # preconfigures glab in the agent container. + provider: gitlab + secretRef: + name: gitlab-token diff --git a/examples/README.md b/examples/README.md index 9e272d41c..5512063ed 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,6 +29,7 @@ Ready-to-use patterns and YAML manifests for orchestrating AI agents with Kelos. | [16-session](16-session/) | Keep one interactive Claude Code, Codex, or OpenCode conversation across web and terminal chat | | [17-taskspawner-ci-remediation](17-taskspawner-ci-remediation/) | Auto-fix failing CI checks via `check_run` webhooks, filtered by conclusion and check name | | [18-webhookgateway](18-webhookgateway/) | Authenticate and route webhook deliveries through named gateways, including multiple GitHub instances | +| [19-taskspawner-gitlab](19-taskspawner-gitlab/) | Create Tasks from GitLab issues and merge requests (gitlab.com, self-hosted, or in-cluster) with trigger commands and status notes | ## Additional Guides diff --git a/examples/helm-values-webhook.yaml b/examples/helm-values-webhook.yaml index 98a73d432..a9885bf80 100644 --- a/examples/helm-values-webhook.yaml +++ b/examples/helm-values-webhook.yaml @@ -15,6 +15,13 @@ webhookServer: replicas: 1 secretName: linear-webhook-secret # Must contain WEBHOOK_SECRET key + # Enable GitLab webhook server (served at /webhook/gitlab) + gitlab: + enabled: true + replicas: 1 + secretName: gitlab-webhook-secret # Must contain WEBHOOK_SECRET key (the X-Gitlab-Token value) + # tokenSecretName: gitlab-api-token # Optional: GITLAB_TOKEN key, enables status notes + # Enable ingress for external webhook access (traditional Ingress API) ingress: enabled: true diff --git a/gemini/Dockerfile b/gemini/Dockerfile index d56873ef5..ac79416a6 100644 --- a/gemini/Dockerfile +++ b/gemini/Dockerfile @@ -26,6 +26,15 @@ RUN ARCH=$(dpkg --print-architecture) \ && tar -C /usr/local -xzf "/tmp/${TARBALL}" \ && rm "/tmp/${TARBALL}" "/tmp/${TARBALL}.sha256" +ARG GLAB_VERSION=1.116.0 +RUN ARCH=$(dpkg --print-architecture) \ + && DEB="glab_${GLAB_VERSION}_linux_${ARCH}.deb" \ + && curl -fsSL -o "/tmp/${DEB}" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${DEB}" \ + && curl -fsSL -o /tmp/glab-checksums.txt "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/checksums.txt" \ + && (cd /tmp && sha256sum --ignore-missing -c glab-checksums.txt) \ + && dpkg -i "/tmp/${DEB}" \ + && rm "/tmp/${DEB}" /tmp/glab-checksums.txt + ENV PATH="/usr/local/go/bin:${PATH}" ARG GEMINI_CLI_VERSION=0.57.0 @@ -37,6 +46,9 @@ RUN chmod +x /kelos_entrypoint.sh COPY hack/agent-gh-wrapper.sh /usr/local/bin/gh RUN chmod +x /usr/local/bin/gh +COPY hack/agent-glab-wrapper.sh /usr/local/bin/glab +RUN chmod +x /usr/local/bin/glab + ARG TARGETARCH COPY bin/kelos-capture-linux-${TARGETARCH} /kelos/kelos-capture diff --git a/hack/agent-glab-wrapper.sh b/hack/agent-glab-wrapper.sh new file mode 100755 index 000000000..c003a6b11 --- /dev/null +++ b/hack/agent-glab-wrapper.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# glab wrapper for Kelos agent containers. +# +# Installed at /usr/local/bin/glab so it shadows the packaged /usr/bin/glab. +# On each invocation, if KELOS_GITLAB_TOKEN_FILE is set and readable, the +# wrapper exports the file contents as GITLAB_TOKEN, then execs the real glab. +# This lets the controller refresh the token in-place via the mounted Secret +# without the long-running agent process picking up stale env vars. +# +# The first invocation also registers the GITLAB_HOST instance in glab's +# config. glab derives the API protocol and port from that host entry, not +# from GITLAB_HOST, so without it a plain-http or non-standard-port instance +# is called over https and `glab auth status` reports the host as +# unauthenticated. + +set -u + +if [ -n "${KELOS_GITLAB_TOKEN_FILE:-}" ] && [ -r "${KELOS_GITLAB_TOKEN_FILE}" ]; then + GITLAB_TOKEN=$(cat "${KELOS_GITLAB_TOKEN_FILE}") + export GITLAB_TOKEN +fi + +__kelos_marker="${GLAB_CONFIG_DIR:-}/.kelos-host-configured" +if [ -n "${GITLAB_HOST:-}" ] && [ -n "${GLAB_CONFIG_DIR:-}" ] && [ -n "${GITLAB_TOKEN:-}" ] && [ ! -f "${__kelos_marker}" ]; then + case "${GITLAB_HOST}" in + http://*) + __kelos_proto=http + __kelos_host=${GITLAB_HOST#http://} + ;; + https://*) + __kelos_proto=https + __kelos_host=${GITLAB_HOST#https://} + ;; + *) + __kelos_proto=https + __kelos_host=${GITLAB_HOST} + ;; + esac + __kelos_host=${__kelos_host%%/*} + mkdir -p "${GLAB_CONFIG_DIR}" + if printf '%s' "${GITLAB_TOKEN}" | /usr/bin/glab auth login \ + --hostname "${__kelos_host}" --api-host "${__kelos_host}" \ + --api-protocol "${__kelos_proto}" --git-protocol "${__kelos_proto}" \ + --stdin >/dev/null 2>&1; then + : >"${__kelos_marker}" + fi + unset __kelos_proto __kelos_host +fi +unset __kelos_marker + +exec /usr/bin/glab "$@" diff --git a/internal/cli/printer.go b/internal/cli/printer.go index 859ede333..d9ee16dcb 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -217,6 +217,8 @@ func printTaskSpawnerTable(w io.Writer, spawners []kelos.TaskSpawner, allNamespa source = "GitHub Pull Requests" } else if s.Spec.When.Jira != nil { source = s.Spec.When.Jira.Project + } else if s.Spec.When.GitLab != nil { + source = "GitLab" } else if s.Spec.When.Cron != nil { source = "cron: " + s.Spec.When.Cron.Schedule } else if s.Spec.When.GitHubWebhook != nil { @@ -226,6 +228,11 @@ func printTaskSpawnerTable(w io.Writer, spawners []kelos.TaskSpawner, allNamespa } } else if s.Spec.When.LinearWebhook != nil { source = "Linear Webhook" + } else if s.Spec.When.GitLabWebhook != nil { + source = "GitLab Webhook" + if s.Spec.When.GitLabWebhook.Project != "" { + source += " (" + s.Spec.When.GitLabWebhook.Project + ")" + } } else if s.Spec.When.GenericWebhook != nil { source = "Generic Webhook (" + s.Spec.When.GenericWebhook.Source + ")" } else if s.Spec.When.Slack != nil { @@ -255,6 +262,8 @@ func effectivePollInterval(ts *kelos.TaskSpawner) string { return ts.Spec.When.GitHubPullRequests.PollInterval case ts.Spec.When.Jira != nil && ts.Spec.When.Jira.PollInterval != "": return ts.Spec.When.Jira.PollInterval + case ts.Spec.When.GitLab != nil && ts.Spec.When.GitLab.PollInterval != "": + return ts.Spec.When.GitLab.PollInterval } return "5m" } @@ -297,6 +306,21 @@ func printTaskSpawnerDetail(w io.Writer, ts *kelos.TaskSpawner) { if jira.JQL != "" { printField(w, "JQL", jira.JQL) } + } else if ts.Spec.When.GitLab != nil { + gl := ts.Spec.When.GitLab + printField(w, "Source", "GitLab") + if gl.Project != "" { + printField(w, "Project", gl.Project) + } + if len(gl.Types) > 0 { + printField(w, "Types", fmt.Sprintf("%v", gl.Types)) + } + if gl.State != "" { + printField(w, "State", gl.State) + } + if len(gl.Labels) > 0 { + printField(w, "Labels", fmt.Sprintf("%v", gl.Labels)) + } } else if ts.Spec.When.Cron != nil { printField(w, "Source", "Cron") printField(w, "Schedule", ts.Spec.When.Cron.Schedule) @@ -314,6 +338,13 @@ func printTaskSpawnerDetail(w io.Writer, ts *kelos.TaskSpawner) { lw := ts.Spec.When.LinearWebhook printField(w, "Source", "Linear Webhook") printField(w, "Types", fmt.Sprintf("%v", lw.Types)) + } else if ts.Spec.When.GitLabWebhook != nil { + gl := ts.Spec.When.GitLabWebhook + printField(w, "Source", "GitLab Webhook") + printField(w, "Events", fmt.Sprintf("%v", gl.Events)) + if gl.Project != "" { + printField(w, "Project", gl.Project) + } } else if ts.Spec.When.GenericWebhook != nil { gw := ts.Spec.When.GenericWebhook printField(w, "Source", "Generic Webhook") diff --git a/internal/cli/printer_test.go b/internal/cli/printer_test.go index 3261e6983..cfb9267f4 100644 --- a/internal/cli/printer_test.go +++ b/internal/cli/printer_test.go @@ -542,6 +542,70 @@ func TestPrintTaskSpawnerTableJira(t *testing.T) { } } +func TestPrintTaskSpawnerTableGitLabWebhook(t *testing.T) { + spawners := []kelos.TaskSpawner{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab-webhook-spawner", + CreationTimestamp: metav1.NewTime(time.Now().Add(-1 * time.Hour)), + }, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{Events: []string{"merge_request"}, Project: "group/repo"}}, + }, + }, + } + + var buf bytes.Buffer + printTaskSpawnerTable(&buf, spawners, false) + if output := buf.String(); !strings.Contains(output, "GitLab Webhook (group/repo)") { + t.Errorf("expected GitLab Webhook source with project in output, got %q", output) + } + + buf.Reset() + printTaskSpawnerDetail(&buf, &spawners[0]) + detail := buf.String() + for _, want := range []string{`Source:\s+GitLab Webhook`, `Events:\s+\[merge_request\]`, `Project:\s+group/repo`} { + if !regexp.MustCompile(want).MatchString(detail) { + t.Errorf("expected %s in detail output, got %q", want, detail) + } + } +} + +func TestPrintTaskSpawnerTableGitLab(t *testing.T) { + spawners := []kelos.TaskSpawner{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab-spawner", + CreationTimestamp: metav1.NewTime(time.Now().Add(-1 * time.Hour)), + }, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{PollInterval: "2m"}}, + }, + Status: kelos.TaskSpawnerStatus{Phase: kelos.TaskSpawnerPhaseRunning}, + }, + } + + var buf bytes.Buffer + printTaskSpawnerTable(&buf, spawners, false) + if output := buf.String(); !strings.Contains(output, "GitLab") { + t.Errorf("expected GitLab as source in output, got %q", output) + } + + if got := effectivePollInterval(&spawners[0]); got != "2m" { + t.Errorf("effectivePollInterval = %q, want %q", got, "2m") + } + + buf.Reset() + spawners[0].Spec.When.GitLab.Project = "group/repo" + printTaskSpawnerDetail(&buf, &spawners[0]) + detail := buf.String() + for _, want := range []string{`Source:\s+GitLab`, `Project:\s+group/repo`} { + if !regexp.MustCompile(want).MatchString(detail) { + t.Errorf("expected %s in detail output, got %q", want, detail) + } + } +} + func TestPrintTaskSpawnerTableGitHubWebhook(t *testing.T) { spawners := []kelos.TaskSpawner{ { diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index 759ba8dfb..ae8175f25 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -395,18 +395,12 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS } var workspaceEnvVars []corev1.EnvVar - var isEnterprise bool + provider := workspaceProviderFor(workspace) effectiveRemotes := effectiveWorkspaceRemotes(workspace) if workspace != nil { - host, _, _ := parseGitHubRepo(workspace.Repo) - isEnterprise = host != "" && host != "github.com" - - if isEnterprise { - // Set GH_HOST for GitHub Enterprise so that gh CLI targets the correct host. - ghHostEnv := corev1.EnvVar{Name: "GH_HOST", Value: host} - envVars = append(envVars, ghHostEnv) - workspaceEnvVars = append(workspaceEnvVars, ghHostEnv) - } + hostEnv := provider.hostEnv(workspace.Repo) + envVars = append(envVars, hostEnv...) + workspaceEnvVars = append(workspaceEnvVars, hostEnv...) if workspace.Ref != "" { envVars = append(envVars, corev1.EnvVar{ @@ -431,49 +425,10 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS } if workspace != nil && workspace.SecretRef != nil { - secretKeyRef := &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: workspace.SecretRef.Name, - }, - Key: GitHubTokenSecretKey, - } - githubTokenEnv := corev1.EnvVar{ - Name: "GITHUB_TOKEN", - ValueFrom: &corev1.EnvVarSource{SecretKeyRef: secretKeyRef}, - } - envVars = append(envVars, githubTokenEnv) - workspaceEnvVars = append(workspaceEnvVars, githubTokenEnv) - - // gh CLI uses GH_TOKEN for github.com and GH_ENTERPRISE_TOKEN for - // GitHub Enterprise Server hosts. - ghTokenName := "GH_TOKEN" - if isEnterprise { - ghTokenName = "GH_ENTERPRISE_TOKEN" - } - ghTokenEnv := corev1.EnvVar{ - Name: ghTokenName, - ValueFrom: &corev1.EnvVarSource{SecretKeyRef: secretKeyRef}, - } - envVars = append(envVars, ghTokenEnv) - workspaceEnvVars = append(workspaceEnvVars, ghTokenEnv) - - // Point gh CLI at a clean config directory on the workspace volume - // so it does not read stale auth from the container image. - envVars = append(envVars, corev1.EnvVar{ - Name: "GH_CONFIG_DIR", - Value: GHConfigDir, - }) - - // Expose the mounted token file path so the git credential - // helper and the gh wrapper script can re-read the token on - // every invocation, picking up controller-side refreshes - // without a pod restart. - tokenFileEnv := corev1.EnvVar{ - Name: "KELOS_GITHUB_TOKEN_FILE", - Value: GitHubTokenMountPath + "/" + GitHubTokenSecretKey, - } - envVars = append(envVars, tokenFileEnv) - workspaceEnvVars = append(workspaceEnvVars, tokenFileEnv) + shared, agentOnly := provider.tokenEnvVars(workspace.Repo, workspace.SecretRef.Name) + envVars = append(envVars, shared...) + envVars = append(envVars, agentOnly...) + workspaceEnvVars = append(workspaceEnvVars, shared...) } backoffLimit := int32(1) @@ -518,23 +473,8 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS // auto-syncing token file. workspaceVolumeMounts := []corev1.VolumeMount{volumeMount} if workspace.SecretRef != nil { - volumes = append(volumes, corev1.Volume{ - Name: GitHubTokenVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: workspace.SecretRef.Name, - Items: []corev1.KeyToPath{ - {Key: GitHubTokenSecretKey, Path: GitHubTokenSecretKey}, - }, - Optional: ptr.To(true), - }, - }, - }) - workspaceVolumeMounts = append(workspaceVolumeMounts, corev1.VolumeMount{ - Name: GitHubTokenVolumeName, - MountPath: GitHubTokenMountPath, - ReadOnly: true, - }) + volumes = append(volumes, provider.tokenVolume(workspace.SecretRef.Name)) + workspaceVolumeMounts = append(workspaceVolumeMounts, provider.tokenVolumeMount()) } cloneArgs := []string{"clone"} @@ -557,13 +497,13 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS if commitRef { credentialHelper := "" if workspace.SecretRef != nil { - credentialHelper = gitCredentialHelper() + credentialHelper = gitCredentialHelper(provider) } - initContainer.Command = []string{"sh", "-c", buildCommitRefCheckoutScript(credentialHelper)} + initContainer.Command = []string{"sh", "-c", buildCommitRefCheckoutScript(credentialHelper, provider.gitUsername)} initContainer.Args = []string{"--", workspace.Repo, targetPath, workspace.Ref} } else if workspace.SecretRef != nil { - credentialHelper := gitCredentialHelper() - credentialConfig := workspaceGitCredentialConfigScript(credentialHelper) + credentialHelper := gitCredentialHelper(provider) + credentialConfig := workspaceGitCredentialConfigScript(credentialHelper, provider.gitUsername) // Clear inherited credential helpers with an empty -c credential.helper= // before setting the workspace helper, then persist the same // configuration into the repo so the agent container is @@ -572,7 +512,7 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS fmt.Sprintf( `git -c credential.helper= -c credential.helper='%s' -c credential.username=%s "$@" && { `+ `%s; }`, - credentialHelper, gitCredentialDefaultUsername, credentialConfig, + credentialHelper, provider.gitUsername, credentialConfig, ), } initContainer.Args = append([]string{"--"}, cloneArgs...) @@ -610,10 +550,10 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS if task.Spec.Branch != "" { remoteGit := "git" if workspace.SecretRef != nil { - credHelper := gitCredentialHelper() + credHelper := gitCredentialHelper(provider) remoteGit = fmt.Sprintf( `git -c credential.helper= -c credential.helper='%s' -c credential.username=%s`, - credHelper, gitCredentialDefaultUsername, + credHelper, provider.gitUsername, ) } branchSetupScript := fmt.Sprintf( @@ -1034,12 +974,12 @@ func isFullGitCommitSHA(ref string) bool { return true } -func buildCommitRefCheckoutScript(credentialHelper string) string { +func buildCommitRefCheckoutScript(credentialHelper, credentialUsername string) string { fetchCmd := `git -C "$target" fetch --depth 1 origin "$ref"` if credentialHelper != "" { fetchCmd = fmt.Sprintf( `git -C "$target" -c credential.helper= -c credential.helper='%s' -c credential.username=%s fetch --depth 1 origin "$ref"`, - credentialHelper, gitCredentialDefaultUsername, + credentialHelper, credentialUsername, ) } @@ -1058,7 +998,7 @@ func buildCommitRefCheckoutScript(credentialHelper string) string { lines = append(lines, `git -C "$target" config --unset-all credential.helper 2>/dev/null || true`, fmt.Sprintf(`git -C "$target" config --add credential.helper '%s'`, credentialHelper), - fmt.Sprintf(`git -C "$target" config credential.username %s`, gitCredentialDefaultUsername), + fmt.Sprintf(`git -C "$target" config credential.username %s`, credentialUsername), ) } @@ -1066,32 +1006,31 @@ func buildCommitRefCheckoutScript(credentialHelper string) string { } // gitCredentialHelper returns the inline git credential helper that resolves -// the GitHub token by reading the mounted token file on each invocation, -// falling back to the inherited $GITHUB_TOKEN env var when the file is not -// present. Reading the file each time lets git pick up controller-side -// token refreshes (e.g. for GitHub App installation tokens that expire -// in ~1h) without restarting the pod. Git's credential.username configuration -// supplies the default username separately so a username in the remote URL -// takes precedence. -func gitCredentialHelper() string { - tokenFile := GitHubTokenMountPath + "/" + GitHubTokenSecretKey - return gitCredentialHelperForTokenFile(tokenFile) +// the workspace token by reading the mounted token file on each invocation, +// falling back to the provider's token env var when the file is not present. +// Reading the file each time lets git pick up controller-side token refreshes +// (e.g. for GitHub App installation tokens that expire in ~1h) without +// restarting the pod. Git's credential.username configuration supplies the +// default username separately so a username in the remote URL takes +// precedence. +func gitCredentialHelper(p workspaceProvider) string { + return gitCredentialHelperForTokenFile(p.tokenFile(), p.tokenEnv) } -func gitCredentialHelperForTokenFile(tokenFile string) string { +func gitCredentialHelperForTokenFile(tokenFile, tokenEnv string) string { return fmt.Sprintf( - `!f() { if [ -r %q ]; then echo "password=$(cat %q)"; else echo "password=$GITHUB_TOKEN"; fi; }; f`, - tokenFile, tokenFile, + `!f() { if [ -r %q ]; then echo "password=$(cat %q)"; else echo "password=$%s"; fi; }; f`, + tokenFile, tokenFile, tokenEnv, ) } -func workspaceGitCredentialConfigScript(credentialHelper string) string { +func workspaceGitCredentialConfigScript(credentialHelper, credentialUsername string) string { return fmt.Sprintf( `git -C %s/repo config --unset-all credential.helper 2>/dev/null || true; `+ `git -C %s/repo config --add credential.helper '%s' && `+ `git -C %s/repo config credential.username %s`, WorkspaceMountPath, WorkspaceMountPath, credentialHelper, - WorkspaceMountPath, gitCredentialDefaultUsername, + WorkspaceMountPath, credentialUsername, ) } diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index 9a889f208..2e33f07e6 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -861,6 +861,93 @@ func TestBuildClaudeCodeJob_WorkspaceWithSecretRefMountsTokenVolume(t *testing.T } } +func TestBuildClaudeCodeJob_GitLabWorkspaceUsesGitLabCredentials(t *testing.T) { + builder := NewJobBuilder() + task := &kelos.Task{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gitlab", Namespace: "default"}, + Spec: kelos.TaskSpec{ + Type: AgentTypeClaudeCode, + Prompt: "Fix the code", + Credentials: &kelos.Credentials{ + Type: kelos.CredentialTypeAPIKey, + SecretRef: &kelos.SecretReference{Name: "my-secret"}, + }, + Branch: "feature-x", + }, + } + workspace := &kelos.WorkspaceSpec{ + Repo: "http://gitlab-webservice-default.gitlab.svc:8181/group/repo.git", + Ref: "main", + Provider: kelos.WorkspaceProviderGitLab, + SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, + } + + job, err := builder.Build(task, workspace, nil, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + mainContainer := job.Spec.Template.Spec.Containers[0] + envMap := map[string]corev1.EnvVar{} + for _, env := range mainContainer.Env { + envMap[env.Name] = env + } + + token, ok := envMap["GITLAB_TOKEN"] + if !ok || token.ValueFrom == nil || token.ValueFrom.SecretKeyRef == nil { + t.Fatalf("expected GITLAB_TOKEN from the workspace secret, got %+v", mainContainer.Env) + } + if token.ValueFrom.SecretKeyRef.Name != "gitlab-token" || token.ValueFrom.SecretKeyRef.Key != "GITLAB_TOKEN" { + t.Errorf("GITLAB_TOKEN secretKeyRef = %+v, want gitlab-token/GITLAB_TOKEN", token.ValueFrom.SecretKeyRef) + } + if envMap["GITLAB_HOST"].Value != "http://gitlab-webservice-default.gitlab.svc:8181" { + t.Errorf("GITLAB_HOST = %q, want the instance URL from the repo", envMap["GITLAB_HOST"].Value) + } + if envMap["GLAB_CONFIG_DIR"].Value != GlabConfigDir || envMap["GLAB_NO_PROMPT"].Value != "true" { + t.Errorf("glab config env missing, got %+v", mainContainer.Env) + } + wantTokenFile := GitLabTokenMountPath + "/" + GitLabTokenSecretKey + if envMap["KELOS_GITLAB_TOKEN_FILE"].Value != wantTokenFile { + t.Errorf("KELOS_GITLAB_TOKEN_FILE = %q, want %q", envMap["KELOS_GITLAB_TOKEN_FILE"].Value, wantTokenFile) + } + for _, name := range []string{"GITHUB_TOKEN", "GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GH_HOST", "GH_CONFIG_DIR", "KELOS_GITHUB_TOKEN_FILE"} { + if _, present := envMap[name]; present { + t.Errorf("%s must not be set for a GitLab workspace", name) + } + } + + var tokenVolume *corev1.Volume + for i := range job.Spec.Template.Spec.Volumes { + if job.Spec.Template.Spec.Volumes[i].Name == GitLabTokenVolumeName { + tokenVolume = &job.Spec.Template.Spec.Volumes[i] + } + if job.Spec.Template.Spec.Volumes[i].Name == GitHubTokenVolumeName { + t.Errorf("GitHub token volume must not be mounted for a GitLab workspace") + } + } + if tokenVolume == nil || tokenVolume.Secret == nil || tokenVolume.Secret.SecretName != "gitlab-token" || tokenVolume.Secret.Items[0].Key != GitLabTokenSecretKey { + t.Fatalf("expected GitLab token volume projecting GITLAB_TOKEN, got %+v", tokenVolume) + } + if !containsVolumeMount(mainContainer.VolumeMounts, GitLabTokenVolumeName, GitLabTokenMountPath) { + t.Errorf("main container missing GitLab token mount; mounts: %+v", mainContainer.VolumeMounts) + } + + for _, ic := range job.Spec.Template.Spec.InitContainers { + if ic.Name != "git-clone" && ic.Name != "branch-setup" { + continue + } + script := strings.Join(ic.Command, " ") + if !strings.Contains(script, "credential.username=oauth2") { + t.Errorf("init container %q must use the oauth2 git username, got %q", ic.Name, script) + } + if !strings.Contains(script, wantTokenFile) || !strings.Contains(script, `password=$GITLAB_TOKEN`) { + t.Errorf("init container %q credential helper must read the GitLab token, got %q", ic.Name, script) + } + if strings.Contains(script, "GITHUB_TOKEN") || strings.Contains(script, gitCredentialDefaultUsername) { + t.Errorf("init container %q must not reference GitHub credentials, got %q", ic.Name, script) + } + } +} + func TestBuildClaudeCodeJob_WorkspaceWithoutSecretRefDoesNotMountTokenVolume(t *testing.T) { builder := NewJobBuilder() task := &kelos.Task{ @@ -993,7 +1080,7 @@ func TestGitCredentialHelperUsername(t *testing.T) { args = append(args, "-c", "credential.username="+tt.configuredUsername) } args = append(args, - "-c", "credential.helper="+gitCredentialHelperForTokenFile(tokenFile), + "-c", "credential.helper="+gitCredentialHelperForTokenFile(tokenFile, "GITHUB_TOKEN"), "-c", "credential.username="+gitCredentialDefaultUsername, "credential", "fill", ) diff --git a/internal/controller/session_controller.go b/internal/controller/session_controller.go index e828a3853..afee3023b 100644 --- a/internal/controller/session_controller.go +++ b/internal/controller/session_controller.go @@ -1744,10 +1744,11 @@ func (r *SessionReconciler) buildSessionStatefulSet(session *kelos.Session, work } } credentialHelper := "" + provider := workspaceProviderFor(workspace) if workspace != nil && workspace.SecretRef != nil { - credentialHelper = gitCredentialHelper() + credentialHelper = gitCredentialHelper(provider) } - if err := prepareSessionWorkspaceInit(podSpec.InitContainers, credentialHelper); err != nil { + if err := prepareSessionWorkspaceInit(podSpec.InitContainers, credentialHelper, provider.gitUsername); err != nil { return nil, nil, err } @@ -1870,7 +1871,7 @@ func sessionSelectorLabels(session *kelos.Session) map[string]string { } } -func prepareSessionWorkspaceInit(containers []corev1.Container, credentialHelper string) error { +func prepareSessionWorkspaceInit(containers []corev1.Container, credentialHelper, credentialUsername string) error { for i := range containers { container := &containers[i] switch container.Name { @@ -1879,7 +1880,7 @@ func prepareSessionWorkspaceInit(containers []corev1.Container, credentialHelper if credentialHelper != "" { initializedAction = fmt.Sprintf( `{ %s; } || exit $?; exit 0`, - workspaceGitCredentialConfigScript(credentialHelper), + workspaceGitCredentialConfigScript(credentialHelper, credentialUsername), ) } prefix := `if [ -f ` + sessionInitializedPath + ` ]; then ` + initializedAction + `; fi diff --git a/internal/controller/session_controller_test.go b/internal/controller/session_controller_test.go index 874e513ca..925c9fccf 100644 --- a/internal/controller/session_controller_test.go +++ b/internal/controller/session_controller_test.go @@ -1670,7 +1670,7 @@ func TestPrepareSessionWorkspaceInitPreservesCloneCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { containers := []corev1.Container{tt.container} - if err := prepareSessionWorkspaceInit(containers, ""); err != nil { + if err := prepareSessionWorkspaceInit(containers, "", gitCredentialDefaultUsername); err != nil { t.Fatal(err) } script := "" @@ -1693,14 +1693,14 @@ func TestPrepareSessionWorkspaceInitPreservesCloneCommands(t *testing.T) { } func TestPrepareSessionWorkspaceInitRefreshesCredentials(t *testing.T) { - credentialHelper := gitCredentialHelperForTokenFile("/test/GITHUB_TOKEN") + credentialHelper := gitCredentialHelperForTokenFile("/test/GITHUB_TOKEN", "GITHUB_TOKEN") containers := []corev1.Container{{ Name: "git-clone", Command: []string{"sh", "-c", `git -c credential.helper= "$@"`}, Args: []string{"--", "clone", "repo", "/workspace/repo"}, }} - if err := prepareSessionWorkspaceInit(containers, credentialHelper); err != nil { + if err := prepareSessionWorkspaceInit(containers, credentialHelper, gitCredentialDefaultUsername); err != nil { t.Fatal(err) } script := containers[0].Command[2] diff --git a/internal/controller/task_controller.go b/internal/controller/task_controller.go index 6fde0cb79..6840805ba 100644 --- a/internal/controller/task_controller.go +++ b/internal/controller/task_controller.go @@ -331,6 +331,17 @@ func (r *TaskReconciler) createJob(ctx context.Context, task *kelos.Task) (ctrl. } workspace = &ws.Spec + if workspace.SecretRef != nil { + if err := r.validateWorkspaceToken(ctx, task, workspace); err != nil { + logger.Error(err, "Workspace secret is missing the provider token") + r.recordEvent(task, corev1.EventTypeWarning, "WorkspaceTokenMissing", "%s", err.Error()) + if updateErr := r.failTaskBeforeJob(ctx, task, err.Error()); updateErr != nil { + logger.Error(updateErr, "Unable to update Task status") + } + return ctrl.Result{}, nil + } + } + // Handle GitHub App authentication if workspace.SecretRef != nil { resolvedWorkspace, err := r.resolveGitHubAppToken(ctx, task, workspace) @@ -528,6 +539,25 @@ func (r *TaskReconciler) validateSkillsAuthSecrets(ctx context.Context, namespac return nil } +// validateWorkspaceToken fails fast when a non-GitHub workspace secret lacks +// the token key its provider requires, instead of letting the clone fail in +// the pod. GitHub secrets are exempt because GitHub App credentials carry no +// token key; resolveGitHubAppToken handles them. +func (r *TaskReconciler) validateWorkspaceToken(ctx context.Context, task *kelos.Task, workspace *kelos.WorkspaceSpec) error { + provider := workspaceProviderFor(workspace) + if provider.name == kelos.WorkspaceProviderGitHub { + return nil + } + var secret corev1.Secret + if err := r.Get(ctx, client.ObjectKey{ + Namespace: task.Namespace, + Name: workspace.SecretRef.Name, + }, &secret); err != nil { + return fmt.Errorf("fetching workspace secret %q: %w", workspace.SecretRef.Name, err) + } + return workspaceSecretTokenError(provider, workspace.SecretRef.Name, secret.Data) +} + // resolveGitHubAppToken checks if the workspace secret is a GitHub App secret, // and if so, generates an installation token and creates a new secret with // the GITHUB_TOKEN key. Returns a modified workspace spec pointing to the diff --git a/internal/controller/taskspawner_controller.go b/internal/controller/taskspawner_controller.go index 9d68fcbd0..99773a4d5 100644 --- a/internal/controller/taskspawner_controller.go +++ b/internal/controller/taskspawner_controller.go @@ -2,6 +2,7 @@ package controller import ( "context" + "errors" "fmt" "reflect" "strings" @@ -64,7 +65,7 @@ func isCronBased(ts *kelos.TaskSpawner) bool { // Slack uses Socket Mode (outbound WebSocket) handled by the centralized // kelos-slack-server, so it follows the same no-deployment pattern. func isWebhookBased(ts *kelos.TaskSpawner) bool { - return ts.Spec.When.GitHubWebhook != nil || ts.Spec.When.LinearWebhook != nil || ts.Spec.When.GenericWebhook != nil || ts.Spec.When.Slack != nil + return ts.Spec.When.GitHubWebhook != nil || ts.Spec.When.LinearWebhook != nil || ts.Spec.When.GitLabWebhook != nil || ts.Spec.When.GenericWebhook != nil || ts.Spec.When.Slack != nil } // Reconcile handles TaskSpawner reconciliation. @@ -129,6 +130,18 @@ func (r *TaskSpawnerReconciler) reconcileWebhook(ctx context.Context, req ctrl.R return ctrl.Result{}, err } + // Webhook spawners create Tasks on delivery, so a Workspace that cannot + // serve the source must fail the spawner here rather than every Task. + if _, _, _, result, err := r.resolveTaskSpawnerWorkspace(ctx, ts); err != nil { + var invalid *workspaceValidationError + if errors.As(err, &invalid) { + return r.failInvalidWorkspace(ctx, req, ts, invalid) + } + return ctrl.Result{}, err + } else if result != (ctrl.Result{}) { + return result, nil + } + // Determine the desired phase for webhook TaskSpawners desiredPhase := kelos.TaskSpawnerPhaseRunning desiredMessage := "Webhook-driven TaskSpawner ready" @@ -214,6 +227,7 @@ func (r *TaskSpawnerReconciler) resolveTaskSpawnerWorkspace(ctx context.Context, workspace := &ws.Spec isGitHubApp := false + var secretData map[string][]byte if workspace.SecretRef != nil { var secret corev1.Secret if err := r.Get(ctx, client.ObjectKey{ @@ -228,6 +242,7 @@ func (r *TaskSpawnerReconciler) resolveTaskSpawnerWorkspace(ctx context.Context, logger.Error(err, "Unable to fetch workspace secret", "secret", workspace.SecretRef.Name) return nil, workspaceRef, false, ctrl.Result{}, err } else { + secretData = secret.Data isGitHubApp = githubapp.IsGitHubApp(secret.Data) if isGitHubApp { logger.Info("Detected GitHub App secret for TaskSpawner", "secret", workspace.SecretRef.Name) @@ -235,9 +250,36 @@ func (r *TaskSpawnerReconciler) resolveTaskSpawnerWorkspace(ctx context.Context, } } + if err := validateTaskSpawnerWorkspace(ts, workspace, secretData); err != nil { + return nil, workspaceRef, false, ctrl.Result{}, err + } + return workspace, workspaceRef, isGitHubApp, ctrl.Result{}, nil } +// failInvalidWorkspace marks a TaskSpawner Failed because its Workspace cannot +// serve the configured source, so the operator sees the reason in status +// instead of a crash-looping spawner. Any workload created for an earlier, +// valid configuration is deleted by the caller. +func (r *TaskSpawnerReconciler) failInvalidWorkspace(ctx context.Context, req ctrl.Request, ts *kelos.TaskSpawner, invalid *workspaceValidationError) (ctrl.Result, error) { + logger := log.FromContext(ctx) + r.recordEvent(ts, corev1.EventTypeWarning, "InvalidWorkspace", "%s", invalid.Error()) + if statusErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if getErr := r.Get(ctx, req.NamespacedName, ts); getErr != nil { + return getErr + } + ts.Status.Phase = kelos.TaskSpawnerPhaseFailed + ts.Status.Message = invalid.Error() + ts.Status.DeploymentName = "" + ts.Status.CronJobName = "" + return r.Status().Update(ctx, ts) + }); statusErr != nil { + logger.Error(statusErr, "Unable to update TaskSpawner status for invalid workspace") + return ctrl.Result{}, statusErr + } + return ctrl.Result{}, nil +} + func (r *TaskSpawnerReconciler) resolveTaskSpawnerWorkspaceRef(ctx context.Context, ts *kelos.TaskSpawner) (*kelos.WorkspaceReference, ctrl.Result, error) { logger := log.FromContext(ctx) template := ts.Spec.TaskTemplate @@ -317,6 +359,16 @@ func (r *TaskSpawnerReconciler) reconcileDeployment(ctx context.Context, req ctr workspace, workspaceRef, isGitHubApp, result, err := r.resolveTaskSpawnerWorkspace(ctx, ts) if err != nil { + var invalid *workspaceValidationError + if errors.As(err, &invalid) { + if deployExists { + if deleteErr := r.Delete(ctx, &deploy); deleteErr != nil && !apierrors.IsNotFound(deleteErr) { + logger.Error(deleteErr, "Unable to delete Deployment for invalid workspace", "deployment", deploy.Name) + return ctrl.Result{}, deleteErr + } + } + return r.failInvalidWorkspace(ctx, req, ts, invalid) + } return ctrl.Result{}, err } if result != (ctrl.Result{}) { @@ -425,6 +477,16 @@ func (r *TaskSpawnerReconciler) reconcileCronJob(ctx context.Context, req ctrl.R workspace, workspaceRef, isGitHubApp, result, err := r.resolveTaskSpawnerWorkspace(ctx, ts) if err != nil { + var invalid *workspaceValidationError + if errors.As(err, &invalid) { + if cronJobExists { + if deleteErr := r.Delete(ctx, &cronJob); deleteErr != nil && !apierrors.IsNotFound(deleteErr) { + logger.Error(deleteErr, "Unable to delete CronJob for invalid workspace", "cronJob", cronJob.Name) + return ctrl.Result{}, deleteErr + } + } + return r.failInvalidWorkspace(ctx, req, ts, invalid) + } return ctrl.Result{}, err } if result != (ctrl.Result{}) { diff --git a/internal/controller/taskspawner_controller_test.go b/internal/controller/taskspawner_controller_test.go index aeb1a9ac5..b8935bbef 100644 --- a/internal/controller/taskspawner_controller_test.go +++ b/internal/controller/taskspawner_controller_test.go @@ -270,6 +270,181 @@ func TestReconcileDeploymentRequeuesWhenWorkspaceSecretMissing(t *testing.T) { assert.True(t, apierrors.IsNotFound(err), "expected no Deployment while workspace secret is missing") } +func TestReconcileDeploymentFailsOnInvalidWorkspace(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, kelos.AddToScheme(scheme)) + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + tests := []struct { + name string + when kelos.When + workspace kelos.WorkspaceSpec + secret *corev1.Secret + wantMessage string + }{ + { + name: "gitlab source with github workspace", + when: kelos.When{GitLab: &kelos.GitLab{}}, + workspace: kelos.WorkspaceSpec{Repo: "https://gitlab.example.com/group/repo.git", SecretRef: &kelos.SecretReference{Name: "token"}}, + secret: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "token", Namespace: "default"}, Data: map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}}, + wantMessage: "requires a Workspace with provider gitlab", + }, + { + name: "gitlab workspace secret without GITLAB_TOKEN", + when: kelos.When{GitLab: &kelos.GitLab{}}, + workspace: kelos.WorkspaceSpec{Repo: "https://gitlab.example.com/group/repo.git", Provider: kelos.WorkspaceProviderGitLab, SecretRef: &kelos.SecretReference{Name: "token"}}, + secret: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "token", Namespace: "default"}, Data: map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}}, + wantMessage: `secret "token" has no GITLAB_TOKEN key`, + }, + { + name: "github source with gitlab workspace", + when: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, + workspace: kelos.WorkspaceSpec{Repo: "https://gitlab.example.com/group/repo.git", Provider: kelos.WorkspaceProviderGitLab, SecretRef: &kelos.SecretReference{Name: "token"}}, + secret: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "token", Namespace: "default"}, Data: map[string][]byte{"GITLAB_TOKEN": []byte("glpat")}}, + wantMessage: "requires a Workspace with provider github", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "spawner", Namespace: "default"}, + Spec: kelos.TaskSpawnerSpec{ + When: tt.when, + TaskTemplate: kelos.TaskTemplate{WorkspaceRef: &kelos.WorkspaceReference{Name: "workspace"}}, + }, + } + ws := &kelos.Workspace{ + ObjectMeta: metav1.ObjectMeta{Name: "workspace", Namespace: "default"}, + Spec: tt.workspace, + } + // A Deployment left over from an earlier, valid configuration must + // be removed so the stale spawner stops polling. + staleDeploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "spawner", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kelos.dev/taskspawner": "spawner"}}, + }, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&kelos.TaskSpawner{}). + WithObjects(ts, ws, tt.secret, staleDeploy). + Build() + r := &TaskSpawnerReconciler{ + Client: cl, + Scheme: scheme, + DeploymentBuilder: NewDeploymentBuilder(), + } + + result, err := r.reconcileDeployment(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "spawner", Namespace: "default"}, + }, ts, false) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result, "invalid workspace must not be requeued") + + var updated kelos.TaskSpawner + require.NoError(t, cl.Get(context.Background(), types.NamespacedName{Name: "spawner", Namespace: "default"}, &updated)) + assert.Equal(t, kelos.TaskSpawnerPhaseFailed, updated.Status.Phase) + assert.Contains(t, updated.Status.Message, tt.wantMessage) + + var deploy appsv1.Deployment + err = cl.Get(context.Background(), types.NamespacedName{Name: "spawner", Namespace: "default"}, &deploy) + assert.True(t, apierrors.IsNotFound(err), "expected the stale Deployment to be deleted") + }) + } +} + +func TestReconcileWebhookFailsOnInvalidWorkspace(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, kelos.AddToScheme(scheme)) + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "spawner", Namespace: "default"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{Events: []string{"note"}}}, + TaskTemplate: kelos.TaskTemplate{WorkspaceRef: &kelos.WorkspaceReference{Name: "workspace"}}, + }, + } + ws := &kelos.Workspace{ + ObjectMeta: metav1.ObjectMeta{Name: "workspace", Namespace: "default"}, + Spec: kelos.WorkspaceSpec{Repo: "https://gitlab.example.com/group/repo.git", SecretRef: &kelos.SecretReference{Name: "token"}}, + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "token", Namespace: "default"}, Data: map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}} + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&kelos.TaskSpawner{}). + WithObjects(ts, ws, secret). + Build() + r := &TaskSpawnerReconciler{Client: cl, Scheme: scheme, DeploymentBuilder: NewDeploymentBuilder()} + + result, err := r.reconcileWebhook(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "spawner", Namespace: "default"}, + }, ts, false) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result, "invalid workspace must not be requeued") + + var updated kelos.TaskSpawner + require.NoError(t, cl.Get(context.Background(), types.NamespacedName{Name: "spawner", Namespace: "default"}, &updated)) + assert.Equal(t, kelos.TaskSpawnerPhaseFailed, updated.Status.Phase) + assert.Contains(t, updated.Status.Message, "requires a Workspace with provider gitlab") +} + +func TestReconcileDeploymentGitLabWorkspaceInjectsGitLabToken(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, kelos.AddToScheme(scheme)) + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "spawner", Namespace: "default"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{}}, + TaskTemplate: kelos.TaskTemplate{WorkspaceRef: &kelos.WorkspaceReference{Name: "workspace"}}, + }, + } + ws := &kelos.Workspace{ + ObjectMeta: metav1.ObjectMeta{Name: "workspace", Namespace: "default"}, + Spec: kelos.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/repo.git", + Provider: kelos.WorkspaceProviderGitLab, + SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, + }, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gitlab-token", Namespace: "default"}, + Data: map[string][]byte{"GITLAB_TOKEN": []byte("glpat")}, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&kelos.TaskSpawner{}). + WithObjects(ts, ws, secret). + Build() + r := &TaskSpawnerReconciler{Client: cl, Scheme: scheme, DeploymentBuilder: NewDeploymentBuilder()} + + _, err := r.reconcileDeployment(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "spawner", Namespace: "default"}, + }, ts, false) + require.NoError(t, err) + + var deploy appsv1.Deployment + require.NoError(t, cl.Get(context.Background(), types.NamespacedName{Name: "spawner", Namespace: "default"}, &deploy)) + env := deploy.Spec.Template.Spec.Containers[0].Env + require.Len(t, env, 1) + assert.Equal(t, "GITLAB_TOKEN", env[0].Name) + assert.Equal(t, "gitlab-token", env[0].ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "GITLAB_TOKEN", env[0].ValueFrom.SecretKeyRef.Key) +} + func TestReconcileWebhook(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, kelos.AddToScheme(scheme)) diff --git a/internal/controller/taskspawner_deployment_builder.go b/internal/controller/taskspawner_deployment_builder.go index ffd0c3e28..3ca611772 100644 --- a/internal/controller/taskspawner_deployment_builder.go +++ b/internal/controller/taskspawner_deployment_builder.go @@ -60,32 +60,36 @@ func (b *DeploymentBuilder) buildPodParts(ts *kelos.TaskSpawner, workspace *kelo var envVars []corev1.EnvVar if workspace != nil { - host, owner, repo := parseGitHubRepo(workspace.Repo) - - // Override with an explicit GitHub source repo if set (fork workflow). - if repoOverride := githubSourceRepoOverride(ts); repoOverride != "" { - overrideHost, overrideOwner, overrideRepo := parseGitHubRepo(repoOverride) - owner = overrideOwner - repo = overrideRepo - // Only override the host when the override itself provides one. - // Shorthand "owner/repo" returns an empty host from parseGitHubRepo; - // in that case keep the workspace host so GHES API URLs are preserved. - if overrideHost != "" { - host = overrideHost + if gl := ts.Spec.When.GitLab; gl != nil { + args = append(args, gitLabSourceArgs(gl, workspace.Repo)...) + } else { + host, owner, repo := parseGitHubRepo(workspace.Repo) + + // Override with an explicit GitHub source repo if set (fork workflow). + if repoOverride := githubSourceRepoOverride(ts); repoOverride != "" { + overrideHost, overrideOwner, overrideRepo := parseGitHubRepo(repoOverride) + owner = overrideOwner + repo = overrideRepo + // Only override the host when the override itself provides one. + // Shorthand "owner/repo" returns an empty host from parseGitHubRepo; + // in that case keep the workspace host so GHES API URLs are preserved. + if overrideHost != "" { + host = overrideHost + } } - } - args = append(args, - "--github-owner="+owner, - "--github-repo="+repo, - ) - if workspaceUsesGHProxy(workspace) && ts.Spec.TaskTemplate.WorkspaceRef != nil { - args = append(args, "--gh-proxy-url="+WorkspaceGHProxyServiceURL(ts.Namespace, ts.Spec.TaskTemplate.WorkspaceRef.Name)) - } - if apiBaseURL := gitHubAPIBaseURL(host); apiBaseURL != "" { - args = append(args, "--github-api-base-url="+apiBaseURL) + args = append(args, + "--github-owner="+owner, + "--github-repo="+repo, + ) + if workspaceUsesGHProxy(workspace) && ts.Spec.TaskTemplate.WorkspaceRef != nil { + args = append(args, "--gh-proxy-url="+WorkspaceGHProxyServiceURL(ts.Namespace, ts.Spec.TaskTemplate.WorkspaceRef.Name)) + } + if apiBaseURL := gitHubAPIBaseURL(host); apiBaseURL != "" { + args = append(args, "--github-api-base-url="+apiBaseURL) + } } - if workspace.SecretRef != nil && taskSpawnerNeedsGitHubToken(ts, workspaceUsesGHProxy(workspace)) { + if workspace.SecretRef != nil && taskSpawnerNeedsWorkspaceToken(ts, workspaceUsesGHProxy(workspace)) { if isGitHubApp { // GitHub App: inject credentials as env vars for in-process token generation envVars = append(envVars, @@ -124,15 +128,16 @@ func (b *DeploymentBuilder) buildPodParts(ts *kelos.TaskSpawner, workspace *kelo }, ) } else { - // PAT: inject GITHUB_TOKEN from secret + // PAT: inject the provider's token from the workspace secret + provider := workspaceProviderFor(workspace) envVars = append(envVars, corev1.EnvVar{ - Name: "GITHUB_TOKEN", + Name: provider.tokenEnv, ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: workspace.SecretRef.Name, }, - Key: "GITHUB_TOKEN", + Key: provider.secretKey, }, }, }) @@ -359,14 +364,55 @@ func githubSourceRepoOverride(ts *kelos.TaskSpawner) string { return "" } -func taskSpawnerNeedsGitHubToken(ts *kelos.TaskSpawner, ghProxyConfigured bool) bool { +// taskSpawnerNeedsWorkspaceToken reports whether the spawner needs the +// workspace token for API calls: GitHub sources unless a ghproxy fronts them +// (reporting still needs it), and GitLab sources always. +func taskSpawnerNeedsWorkspaceToken(ts *kelos.TaskSpawner, ghProxyConfigured bool) bool { if ts.Spec.When.GitHubIssues != nil { return !ghProxyConfigured || gitHubReportingNeedsToken(ts.Spec.When.GitHubIssues.Reporting) } if ts.Spec.When.GitHubPullRequests != nil { return !ghProxyConfigured || gitHubReportingNeedsToken(ts.Spec.When.GitHubPullRequests.Reporting) } - return false + return ts.Spec.When.GitLab != nil +} + +// gitLabSourceArgs returns the spawner flags for a GitLab source. The +// instance URL and project path default to the workspace repo URL and are +// individually overridable from the source spec. +func gitLabSourceArgs(gl *kelos.GitLab, workspaceRepo string) []string { + baseURL, project := parseGitLabRepo(workspaceRepo) + if gl.BaseURL != "" { + baseURL = gl.BaseURL + } + if gl.Project != "" { + project = gl.Project + } + return []string{ + "--gitlab-base-url=" + baseURL, + "--gitlab-project=" + project, + } +} + +// parseGitLabRepo splits a GitLab repository URL into the instance base URL +// and the full project path, e.g. https://gitlab.example.com/group/sub/repo.git +// yields ("https://gitlab.example.com", "group/sub/repo"). SSH URLs +// (git@host:group/repo.git) map to an https base URL. Any username in the URL +// is dropped. +func parseGitLabRepo(repoURL string) (baseURL, project string) { + repoURL = strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(repoURL), "/"), ".git") + + if strings.HasPrefix(repoURL, "git@") { + hostAndPath := strings.TrimPrefix(repoURL, "git@") + host, path, _ := strings.Cut(hostAndPath, ":") + return "https://" + host, strings.Trim(path, "/") + } + + parsed, err := url.Parse(repoURL) + if err != nil || parsed.Host == "" { + return "", strings.Trim(repoURL, "/") + } + return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String(), strings.Trim(parsed.Path, "/") } func gitHubReportingNeedsToken(reporting *kelos.GitHubReporting) bool { diff --git a/internal/controller/taskspawner_deployment_builder_test.go b/internal/controller/taskspawner_deployment_builder_test.go index cbface8f8..639b90699 100644 --- a/internal/controller/taskspawner_deployment_builder_test.go +++ b/internal/controller/taskspawner_deployment_builder_test.go @@ -726,6 +726,96 @@ func TestDeploymentBuilder_PAT(t *testing.T) { } } +func TestParseGitLabRepo(t *testing.T) { + tests := []struct { + repoURL string + wantBaseURL string + wantProject string + }{ + {"https://gitlab.com/group/repo.git", "https://gitlab.com", "group/repo"}, + {"https://gitlab.example.com/group/sub/repo.git", "https://gitlab.example.com", "group/sub/repo"}, + {"https://oauth2@gitlab.example.com/group/repo", "https://gitlab.example.com", "group/repo"}, + {"http://gitlab-webservice-default.gitlab.svc:8181/group/repo.git", "http://gitlab-webservice-default.gitlab.svc:8181", "group/repo"}, + {"git@gitlab.example.com:group/sub/repo.git", "https://gitlab.example.com", "group/sub/repo"}, + {"group/repo", "", "group/repo"}, + } + for _, tt := range tests { + t.Run(tt.repoURL, func(t *testing.T) { + baseURL, project := parseGitLabRepo(tt.repoURL) + if baseURL != tt.wantBaseURL || project != tt.wantProject { + t.Errorf("parseGitLabRepo(%q) = (%q, %q), want (%q, %q)", tt.repoURL, baseURL, project, tt.wantBaseURL, tt.wantProject) + } + }) + } +} + +func TestDeploymentBuilder_GitLab(t *testing.T) { + builder := NewDeploymentBuilder() + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "test-spawner", Namespace: "default"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{}}, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + WorkspaceRef: &kelos.WorkspaceReference{Name: "ws"}, + }, + }, + } + workspace := &kelos.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/sub/repo.git", + Provider: kelos.WorkspaceProviderGitLab, + SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, + } + + deploy := builder.Build(ts, workspace, false) + spawner := deploy.Spec.Template.Spec.Containers[0] + + args := strings.Join(spawner.Args, " ") + if !strings.Contains(args, "--gitlab-base-url=https://gitlab.example.com") || !strings.Contains(args, "--gitlab-project=group/sub/repo") { + t.Errorf("expected gitlab args derived from workspace repo, got %v", spawner.Args) + } + if strings.Contains(args, "--github-owner") || strings.Contains(args, "--github-api-base-url") { + t.Errorf("expected no GitHub args for a GitLab source, got %v", spawner.Args) + } + + if len(spawner.Env) != 1 || spawner.Env[0].Name != "GITLAB_TOKEN" { + t.Fatalf("expected GITLAB_TOKEN env from workspace secret, got %v", spawner.Env) + } + ref := spawner.Env[0].ValueFrom.SecretKeyRef + if ref.Name != "gitlab-token" || ref.Key != "GITLAB_TOKEN" { + t.Errorf("unexpected secret key ref: %+v", ref) + } +} + +func TestDeploymentBuilder_GitLabOverrides(t *testing.T) { + builder := NewDeploymentBuilder() + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "test-spawner", Namespace: "default"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{ + BaseURL: "http://gitlab-webservice-default.gitlab.svc:8181", + Project: "upstream/repo", + }}, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + WorkspaceRef: &kelos.WorkspaceReference{Name: "ws"}, + }, + }, + } + workspace := &kelos.WorkspaceSpec{Repo: "https://gitlab.example.com/fork/repo.git"} + + deploy := builder.Build(ts, workspace, false) + spawner := deploy.Spec.Template.Spec.Containers[0] + + args := strings.Join(spawner.Args, " ") + if !strings.Contains(args, "--gitlab-base-url=http://gitlab-webservice-default.gitlab.svc:8181") || !strings.Contains(args, "--gitlab-project=upstream/repo") { + t.Errorf("expected explicit overrides in args, got %v", spawner.Args) + } + if len(spawner.Env) != 0 { + t.Errorf("expected no env without a workspace secret, got %v", spawner.Env) + } +} + func TestDeploymentBuilder_Jira(t *testing.T) { builder := NewDeploymentBuilder() ts := &kelos.TaskSpawner{ diff --git a/internal/controller/webhookgateway_controller.go b/internal/controller/webhookgateway_controller.go index f83e6a7e9..76f20819f 100644 --- a/internal/controller/webhookgateway_controller.go +++ b/internal/controller/webhookgateway_controller.go @@ -110,10 +110,21 @@ func (r *WebhookGatewayReconciler) evaluate(ctx context.Context, gw *kelos.Webho } return kelos.WebhookGatewayPhaseAuthenticated, "", false, nil + case gw.Spec.GitLab != nil: + if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitLab.SecretRef.Name, "webhook token secret"); err != nil || phase != "" { + return phase, msg, requeue, err + } + if gw.Spec.GitLab.CredentialsRef != nil { + if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitLab.CredentialsRef.Name, "credentials secret"); err != nil || phase != "" { + return phase, msg, requeue, err + } + } + return kelos.WebhookGatewayPhaseAuthenticated, "", false, nil + default: // The CEL "exactly one of" rule should prevent reaching here. return kelos.WebhookGatewayPhaseSecretMissing, - "no source configured: exactly one of github, linear, or generic is required", false, nil + "no source configured: exactly one of github, linear, gitlab, or generic is required", false, nil } } @@ -175,7 +186,8 @@ func (r *WebhookGatewayReconciler) findGatewaysForSecret(ctx context.Context, ob } // gatewayReferencesSecret reports whether the gateway references the named -// Secret — the inbound HMAC secret or, for github, the outbound credentials. +// Secret — the inbound HMAC secret or token, or the outbound credentials for +// github and gitlab. func gatewayReferencesSecret(gw *kelos.WebhookGateway, name string) bool { switch { case gw.Spec.GitHub != nil: @@ -183,6 +195,9 @@ func gatewayReferencesSecret(gw *kelos.WebhookGateway, name string) bool { (gw.Spec.GitHub.CredentialsRef != nil && gw.Spec.GitHub.CredentialsRef.Name == name) case gw.Spec.Linear != nil: return gw.Spec.Linear.SecretRef.Name == name + case gw.Spec.GitLab != nil: + return gw.Spec.GitLab.SecretRef.Name == name || + (gw.Spec.GitLab.CredentialsRef != nil && gw.Spec.GitLab.CredentialsRef.Name == name) default: return false } diff --git a/internal/controller/webhookgateway_controller_test.go b/internal/controller/webhookgateway_controller_test.go index 46d6f2e37..a32d4a8fb 100644 --- a/internal/controller/webhookgateway_controller_test.go +++ b/internal/controller/webhookgateway_controller_test.go @@ -130,6 +130,64 @@ func TestWebhookGatewayReconciler_LinearAuthenticated(t *testing.T) { } } +func TestWebhookGatewayReconciler_GitLabAuthenticated(t *testing.T) { + gw := &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, + Spec: kelos.WebhookGatewaySpec{ + GitLab: &kelos.GitLabGateway{ + SecretRef: kelos.SecretReference{Name: "gl-secret"}, + }, + }, + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}} + got, _ := reconcileGateway(t, gw, secret) + if got.Status.Phase != kelos.WebhookGatewayPhaseAuthenticated { + t.Errorf("phase = %q, want Authenticated", got.Status.Phase) + } + if !gatewayReferencesSecret(gw, "gl-secret") || gatewayReferencesSecret(gw, "other") { + t.Error("expected gateway to reference only its token secret") + } +} + +func TestWebhookGatewayReconciler_GitLabCredentialsAbsent(t *testing.T) { + gw := &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, + Spec: kelos.WebhookGatewaySpec{ + GitLab: &kelos.GitLabGateway{ + SecretRef: kelos.SecretReference{Name: "gl-secret"}, + CredentialsRef: &kelos.SecretReference{Name: "absent-creds"}, + }, + }, + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}} + got, res := reconcileGateway(t, gw, secret) + if got.Status.Phase != kelos.WebhookGatewayPhaseSecretMissing { + t.Errorf("phase = %q, want SecretMissing for absent credentials", got.Status.Phase) + } + if res.RequeueAfter == 0 { + t.Error("expected requeue while the credentials secret is absent") + } + if !gatewayReferencesSecret(gw, "absent-creds") { + t.Error("expected gateway to reference its credentials secret") + } +} + +func TestWebhookGatewayReconciler_GitLabSecretAbsentRequeues(t *testing.T) { + gw := &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, + Spec: kelos.WebhookGatewaySpec{ + GitLab: &kelos.GitLabGateway{SecretRef: kelos.SecretReference{Name: "absent"}}, + }, + } + got, res := reconcileGateway(t, gw) + if got.Status.Phase != kelos.WebhookGatewayPhaseSecretMissing { + t.Errorf("phase = %q, want SecretMissing", got.Status.Phase) + } + if res.RequeueAfter == 0 { + t.Error("expected requeue while the token secret is absent") + } +} + func TestWebhookGatewayReconciler_GitHubCredentialsAbsent(t *testing.T) { gw := &kelos.WebhookGateway{ ObjectMeta: metav1.ObjectMeta{Name: "gh", Namespace: "default"}, diff --git a/internal/controller/workerpool_controller.go b/internal/controller/workerpool_controller.go index 6f1c12e0e..58c86167d 100644 --- a/internal/controller/workerpool_controller.go +++ b/internal/controller/workerpool_controller.go @@ -577,16 +577,11 @@ func (r *WorkerPoolReconciler) buildStatefulSet(pool *kelos.WorkerPool, stsName, // Workspace env vars for init containers and main container var workspaceEnvVars []corev1.EnvVar - var isEnterprise bool + provider := workspaceProviderFor(workspace) if workspace != nil { - host, _, _ := parseGitHubRepo(workspace.Repo) - isEnterprise = host != "" && host != "github.com" - - if isEnterprise { - ghHostEnv := corev1.EnvVar{Name: "GH_HOST", Value: host} - envVars = append(envVars, ghHostEnv) - workspaceEnvVars = append(workspaceEnvVars, ghHostEnv) - } + hostEnv := provider.hostEnv(workspace.Repo) + envVars = append(envVars, hostEnv...) + workspaceEnvVars = append(workspaceEnvVars, hostEnv...) if workspace.Ref != "" { envVars = append(envVars, corev1.EnvVar{ @@ -607,47 +602,13 @@ func (r *WorkerPoolReconciler) buildStatefulSet(pool *kelos.WorkerPool, stsName, } if workspace != nil && workspace.SecretRef != nil { - secretKeyRef := &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: workspace.SecretRef.Name, - }, - Key: "GITHUB_TOKEN", - } - githubTokenEnv := corev1.EnvVar{ - Name: "GITHUB_TOKEN", - ValueFrom: &corev1.EnvVarSource{SecretKeyRef: secretKeyRef}, - } - envVars = append(envVars, githubTokenEnv) - workspaceEnvVars = append(workspaceEnvVars, githubTokenEnv) - - ghTokenName := "GH_TOKEN" - if isEnterprise { - ghTokenName = "GH_ENTERPRISE_TOKEN" - } - ghTokenEnv := corev1.EnvVar{ - Name: ghTokenName, - ValueFrom: &corev1.EnvVarSource{SecretKeyRef: secretKeyRef}, - } - envVars = append(envVars, ghTokenEnv) - workspaceEnvVars = append(workspaceEnvVars, ghTokenEnv) - - // Point gh CLI at a clean config directory on the workspace volume - envVars = append(envVars, corev1.EnvVar{ - Name: "GH_CONFIG_DIR", - Value: GHConfigDir, - }) - - // Expose the mounted token file path so the worker runner can re-read - // the token on every task, picking up controller-side refreshes without - // a pod restart. The secret-backed GITHUB_TOKEN / GH_TOKEN env vars - // above are frozen at pod start, so the file is the source of truth for - // long-lived pools. Only the main container runs the worker runner; the - // init-container credential helper hardcodes the mount path via - // gitCredentialHelper(), so it does not need this env var. - envVars = append(envVars, corev1.EnvVar{ - Name: "KELOS_GITHUB_TOKEN_FILE", - Value: GitHubTokenMountPath + "/" + GitHubTokenSecretKey, - }) + // The Secret-backed token env vars are frozen at pod start, so the + // token file is the source of truth for long-lived pools: the worker + // runner re-reads it on every task. + shared, agentOnly := provider.tokenEnvVars(workspace.Repo, workspace.SecretRef.Name) + envVars = append(envVars, shared...) + envVars = append(envVars, agentOnly...) + workspaceEnvVars = append(workspaceEnvVars, shared...) } workerRunnerVolumeName := "worker-runner" @@ -691,23 +652,8 @@ func (r *WorkerPoolReconciler) buildStatefulSet(pool *kelos.WorkerPool, stsName, // secret-volume updates into the running pod, so a controller-side token // refresh propagates without a pod restart. if workspace != nil && workspace.SecretRef != nil { - volumes = append(volumes, corev1.Volume{ - Name: GitHubTokenVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: workspace.SecretRef.Name, - Items: []corev1.KeyToPath{ - {Key: GitHubTokenSecretKey, Path: GitHubTokenSecretKey}, - }, - Optional: ptr.To(true), - }, - }, - }) - mainContainer.VolumeMounts = append(mainContainer.VolumeMounts, corev1.VolumeMount{ - Name: GitHubTokenVolumeName, - MountPath: GitHubTokenMountPath, - ReadOnly: true, - }) + volumes = append(volumes, provider.tokenVolume(workspace.SecretRef.Name)) + mainContainer.VolumeMounts = append(mainContainer.VolumeMounts, provider.tokenVolumeMount()) } // Build workspace init containers (git-clone, remote-setup, workspace-files) @@ -721,11 +667,7 @@ func (r *WorkerPoolReconciler) buildStatefulSet(pool *kelos.WorkerPool, stsName, // configured, the auto-syncing token file used by the credential helper. workspaceVolumeMounts := []corev1.VolumeMount{volumeMount} if workspace.SecretRef != nil { - workspaceVolumeMounts = append(workspaceVolumeMounts, corev1.VolumeMount{ - Name: GitHubTokenVolumeName, - MountPath: GitHubTokenMountPath, - ReadOnly: true, - }) + workspaceVolumeMounts = append(workspaceVolumeMounts, provider.tokenVolumeMount()) } targetPath := WorkspaceMountPath + "/repo" @@ -752,8 +694,8 @@ func (r *WorkerPoolReconciler) buildStatefulSet(pool *kelos.WorkerPool, stsName, credentialHelper := "" credentialConfig := "" if workspace.SecretRef != nil { - credentialHelper = gitCredentialHelper() - credentialConfig = workspaceGitCredentialConfigScript(credentialHelper) + credentialHelper = gitCredentialHelper(provider) + credentialConfig = workspaceGitCredentialConfigScript(credentialHelper, provider.gitUsername) } if commitRef { @@ -763,7 +705,7 @@ func (r *WorkerPoolReconciler) buildStatefulSet(pool *kelos.WorkerPool, stsName, } gitClone.Command = []string{"sh", "-c", fmt.Sprintf("if [ -d '%s/repo/.git' ]; then echo 'Workspace exists, skipping clone'; %s; fi; %s", - WorkspaceMountPath, existingRepoAction, buildCommitRefCheckoutScript(credentialHelper)), + WorkspaceMountPath, existingRepoAction, buildCommitRefCheckoutScript(credentialHelper, provider.gitUsername)), } gitClone.Args = []string{"--", workspace.Repo, targetPath, workspace.Ref} } else if workspace.SecretRef != nil { @@ -771,7 +713,7 @@ func (r *WorkerPoolReconciler) buildStatefulSet(pool *kelos.WorkerPool, stsName, innerCmd := fmt.Sprintf( `git -c credential.helper= -c credential.helper='%s' -c credential.username=%s "$@" && { `+ `%s; }`, - credentialHelper, gitCredentialDefaultUsername, credentialConfig, + credentialHelper, provider.gitUsername, credentialConfig, ) // Wrap with exists check so it skips if workspace already exists on PVC gitClone.Command = []string{"sh", "-c", diff --git a/internal/controller/workspace_provider.go b/internal/controller/workspace_provider.go new file mode 100644 index 000000000..a677bf79e --- /dev/null +++ b/internal/controller/workspace_provider.go @@ -0,0 +1,223 @@ +package controller + +import ( + "bytes" + "fmt" + + corev1 "k8s.io/api/core/v1" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +const ( + // GitLabTokenSecretKey is the Secret key under which a GitLab access token + // is stored for provider gitlab. Mounted as a file at + // GitLabTokenMountPath + "/" + GitLabTokenSecretKey. + GitLabTokenSecretKey = "GITLAB_TOKEN" + + // GitLabTokenVolumeName is the volume that mounts the workspace token + // Secret into agent and init containers for provider gitlab. + GitLabTokenVolumeName = "kelos-gitlab-token" + + // GitLabTokenMountPath is the directory where the GitLab token Secret is + // mounted. + GitLabTokenMountPath = "/kelos/gitlab-token" + + // GlabConfigDir is the directory used for glab CLI configuration. It is + // placed on the shared workspace volume so glab does not read stale auth + // from the container image's home directory. + GlabConfigDir = WorkspaceMountPath + "/.glab-config" + + gitLabCredentialUsername = "oauth2" +) + +// workspaceProvider is the contract between a Workspace secret and the pods +// that consume it: which Secret key holds the token, where the auto-syncing +// token file is mounted, which git username pairs with the token, and which +// CLI is preconfigured for the agent. +type workspaceProvider struct { + name string + secretKey string + tokenEnv string + tokenFileEnv string + tokenVolumeName string + tokenMountPath string + gitUsername string + // hostEnv returns the CLI host variables for a repo URL. They do not + // depend on a Secret and reach agent and init containers alike. + hostEnv func(repoURL string) []corev1.EnvVar + // cliTokenEnvNames returns the CLI variables that mirror the token. + cliTokenEnvNames func(repoURL string) []string + // cliConfigEnv is static CLI configuration for the agent container only. + cliConfigEnv []corev1.EnvVar +} + +var workspaceProviders = map[string]workspaceProvider{ + kelos.WorkspaceProviderGitHub: { + name: kelos.WorkspaceProviderGitHub, + secretKey: GitHubTokenSecretKey, + tokenEnv: "GITHUB_TOKEN", + tokenFileEnv: "KELOS_GITHUB_TOKEN_FILE", + tokenVolumeName: GitHubTokenVolumeName, + tokenMountPath: GitHubTokenMountPath, + gitUsername: gitCredentialDefaultUsername, + hostEnv: func(repoURL string) []corev1.EnvVar { + if host, enterprise := gitHubEnterpriseHost(repoURL); enterprise { + // GH_HOST points the gh CLI at the GitHub Enterprise host. + return []corev1.EnvVar{{Name: "GH_HOST", Value: host}} + } + return nil + }, + cliTokenEnvNames: func(repoURL string) []string { + // gh reads GH_TOKEN for github.com and GH_ENTERPRISE_TOKEN for + // GitHub Enterprise Server hosts. + if _, enterprise := gitHubEnterpriseHost(repoURL); enterprise { + return []string{"GH_ENTERPRISE_TOKEN"} + } + return []string{"GH_TOKEN"} + }, + cliConfigEnv: []corev1.EnvVar{{Name: "GH_CONFIG_DIR", Value: GHConfigDir}}, + }, + kelos.WorkspaceProviderGitLab: { + name: kelos.WorkspaceProviderGitLab, + secretKey: GitLabTokenSecretKey, + tokenEnv: "GITLAB_TOKEN", + tokenFileEnv: "KELOS_GITLAB_TOKEN_FILE", + tokenVolumeName: GitLabTokenVolumeName, + tokenMountPath: GitLabTokenMountPath, + gitUsername: gitLabCredentialUsername, + hostEnv: func(repoURL string) []corev1.EnvVar { + // glab defaults to gitlab.com; GITLAB_HOST carries the instance URL + // including scheme and port for self-hosted and in-cluster GitLab. + baseURL, _ := parseGitLabRepo(repoURL) + return []corev1.EnvVar{{Name: "GITLAB_HOST", Value: baseURL}} + }, + // glab reads GITLAB_TOKEN directly, so no mirror variable is needed. + cliTokenEnvNames: func(string) []string { return nil }, + cliConfigEnv: []corev1.EnvVar{ + {Name: "GLAB_CONFIG_DIR", Value: GlabConfigDir}, + {Name: "GLAB_NO_PROMPT", Value: "true"}, + {Name: "GLAB_CHECK_UPDATE", Value: "false"}, + {Name: "GLAB_SEND_TELEMETRY", Value: "false"}, + }, + }, +} + +// workspaceProviderFor returns the provider contract for a workspace. An +// unset provider means github, matching the CRD default. +func workspaceProviderFor(workspace *kelos.WorkspaceSpec) workspaceProvider { + if workspace != nil { + if p, ok := workspaceProviders[workspace.Provider]; ok { + return p + } + } + return workspaceProviders[kelos.WorkspaceProviderGitHub] +} + +func gitHubEnterpriseHost(repoURL string) (string, bool) { + host, _, _ := parseGitHubRepo(repoURL) + return host, host != "" && host != "github.com" +} + +func (p workspaceProvider) tokenFile() string { + return p.tokenMountPath + "/" + p.secretKey +} + +// tokenEnvVars returns the Secret-backed token variables plus the token file +// path, which every container that touches the repo needs (shared), and the +// CLI configuration only the agent container needs (agentOnly). +func (p workspaceProvider) tokenEnvVars(repoURL, secretName string) (shared, agentOnly []corev1.EnvVar) { + secretKeyRef := &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: p.secretKey, + } + shared = append(shared, corev1.EnvVar{Name: p.tokenEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: secretKeyRef}}) + for _, name := range p.cliTokenEnvNames(repoURL) { + shared = append(shared, corev1.EnvVar{Name: name, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: secretKeyRef}}) + } + // The mounted token file lets git and CLI wrappers re-read the token on + // every invocation, picking up controller-side refreshes without a pod + // restart. + shared = append(shared, corev1.EnvVar{Name: p.tokenFileEnv, Value: p.tokenFile()}) + return shared, append([]corev1.EnvVar(nil), p.cliConfigEnv...) +} + +// tokenVolume mounts the workspace token Secret as a file. The kubelet +// auto-syncs Secret volume contents, so the controller can refresh the token +// in place. +func (p workspaceProvider) tokenVolume(secretName string) corev1.Volume { + return corev1.Volume{ + Name: p.tokenVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + Items: []corev1.KeyToPath{{Key: p.secretKey, Path: p.secretKey}}, + Optional: ptrTo(true), + }, + }, + } +} + +func (p workspaceProvider) tokenVolumeMount() corev1.VolumeMount { + return corev1.VolumeMount{ + Name: p.tokenVolumeName, + MountPath: p.tokenMountPath, + ReadOnly: true, + } +} + +// workspaceSecretTokenError reports a workspace Secret that lacks the token +// key its provider requires. GitHub is exempt because GitHub App secrets +// legitimately carry appID/installationID/privateKey instead of a token. +func workspaceSecretTokenError(p workspaceProvider, secretName string, data map[string][]byte) error { + if p.name == kelos.WorkspaceProviderGitHub { + return nil + } + if len(bytes.TrimSpace(data[p.secretKey])) == 0 { + return fmt.Errorf("workspace secret %q has no %s key required by provider %s", secretName, p.secretKey, p.name) + } + return nil +} + +// taskSpawnerSourceProvider returns the workspace provider a TaskSpawner +// source is bound to, or "" for sources that work with any provider. +func taskSpawnerSourceProvider(ts *kelos.TaskSpawner) string { + when := ts.Spec.When + switch { + case when.GitLab != nil || when.GitLabWebhook != nil: + return kelos.WorkspaceProviderGitLab + case when.GitHubIssues != nil || when.GitHubPullRequests != nil || when.GitHubWebhook != nil: + return kelos.WorkspaceProviderGitHub + } + return "" +} + +// workspaceValidationError marks a TaskSpawner whose Workspace cannot serve +// its source. The spawner is marked Failed instead of being requeued. +type workspaceValidationError struct{ msg string } + +func (e *workspaceValidationError) Error() string { return e.msg } + +// validateTaskSpawnerWorkspace checks that the Workspace provider matches the +// TaskSpawner source and that the workspace Secret carries the provider's +// token key. secretData is the referenced Secret's data, or nil when the +// Workspace has no SecretRef. +func validateTaskSpawnerWorkspace(ts *kelos.TaskSpawner, workspace *kelos.WorkspaceSpec, secretData map[string][]byte) error { + if workspace == nil { + return nil + } + p := workspaceProviderFor(workspace) + if sourceProvider := taskSpawnerSourceProvider(ts); sourceProvider != "" && sourceProvider != p.name { + return &workspaceValidationError{msg: fmt.Sprintf("TaskSpawner source requires a Workspace with provider %s, but the Workspace provider is %s", sourceProvider, p.name)} + } + if workspace.SecretRef == nil { + if p.name != kelos.WorkspaceProviderGitHub && taskSpawnerSourceProvider(ts) == p.name { + return &workspaceValidationError{msg: fmt.Sprintf("TaskSpawner source requires the Workspace to reference a Secret with a %s key", p.secretKey)} + } + return nil + } + if err := workspaceSecretTokenError(p, workspace.SecretRef.Name, secretData); err != nil { + return &workspaceValidationError{msg: err.Error()} + } + return nil +} diff --git a/internal/controller/workspace_provider_test.go b/internal/controller/workspace_provider_test.go new file mode 100644 index 000000000..16c8a463b --- /dev/null +++ b/internal/controller/workspace_provider_test.go @@ -0,0 +1,190 @@ +package controller + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +func envNames(vars []corev1.EnvVar) []string { + names := make([]string, 0, len(vars)) + for _, v := range vars { + names = append(names, v.Name) + } + return names +} + +func TestWorkspaceProviderFor(t *testing.T) { + tests := []struct { + name string + workspace *kelos.WorkspaceSpec + want string + }{ + {name: "nil workspace defaults to github", workspace: nil, want: kelos.WorkspaceProviderGitHub}, + {name: "empty provider defaults to github", workspace: &kelos.WorkspaceSpec{}, want: kelos.WorkspaceProviderGitHub}, + {name: "gitlab", workspace: &kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitLab}, want: kelos.WorkspaceProviderGitLab}, + {name: "unknown falls back to github", workspace: &kelos.WorkspaceSpec{Provider: "bitbucket"}, want: kelos.WorkspaceProviderGitHub}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := workspaceProviderFor(tt.workspace).name; got != tt.want { + t.Errorf("workspaceProviderFor() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestWorkspaceProviderGitHubContract(t *testing.T) { + p := workspaceProviderFor(&kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitHub}) + + if p.secretKey != "GITHUB_TOKEN" || p.tokenEnv != "GITHUB_TOKEN" || p.tokenFileEnv != "KELOS_GITHUB_TOKEN_FILE" || p.gitUsername != "x-access-token" { + t.Fatalf("unexpected github contract: %+v", p) + } + if p.tokenFile() != GitHubTokenMountPath+"/GITHUB_TOKEN" { + t.Errorf("tokenFile() = %q", p.tokenFile()) + } + + if got := p.hostEnv("https://github.com/org/repo.git"); len(got) != 0 { + t.Errorf("github.com must not set GH_HOST, got %v", got) + } + if got := p.hostEnv("https://ghe.example.com/org/repo.git"); len(got) != 1 || got[0].Name != "GH_HOST" || got[0].Value != "ghe.example.com" { + t.Errorf("enterprise host env = %v, want GH_HOST=ghe.example.com", got) + } + + shared, agentOnly := p.tokenEnvVars("https://github.com/org/repo.git", "github-token") + if got := strings.Join(envNames(shared), ","); got != "GITHUB_TOKEN,GH_TOKEN,KELOS_GITHUB_TOKEN_FILE" { + t.Errorf("shared env = %s", got) + } + if got := strings.Join(envNames(agentOnly), ","); got != "GH_CONFIG_DIR" { + t.Errorf("agent-only env = %s", got) + } + shared, _ = p.tokenEnvVars("https://ghe.example.com/org/repo.git", "github-token") + if got := strings.Join(envNames(shared), ","); got != "GITHUB_TOKEN,GH_ENTERPRISE_TOKEN,KELOS_GITHUB_TOKEN_FILE" { + t.Errorf("enterprise shared env = %s", got) + } + for _, v := range shared[:2] { + if v.ValueFrom == nil || v.ValueFrom.SecretKeyRef == nil || v.ValueFrom.SecretKeyRef.Name != "github-token" || v.ValueFrom.SecretKeyRef.Key != "GITHUB_TOKEN" { + t.Errorf("%s must come from github-token/GITHUB_TOKEN, got %+v", v.Name, v.ValueFrom) + } + } +} + +func TestWorkspaceProviderGitLabContract(t *testing.T) { + p := workspaceProviderFor(&kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitLab}) + + if p.secretKey != "GITLAB_TOKEN" || p.tokenEnv != "GITLAB_TOKEN" || p.tokenFileEnv != "KELOS_GITLAB_TOKEN_FILE" || p.gitUsername != "oauth2" { + t.Fatalf("unexpected gitlab contract: %+v", p) + } + if p.tokenFile() != GitLabTokenMountPath+"/GITLAB_TOKEN" { + t.Errorf("tokenFile() = %q", p.tokenFile()) + } + + got := p.hostEnv("http://gitlab-webservice-default.gitlab.svc:8181/group/repo.git") + if len(got) != 1 || got[0].Name != "GITLAB_HOST" || got[0].Value != "http://gitlab-webservice-default.gitlab.svc:8181" { + t.Errorf("host env = %v, want GITLAB_HOST with scheme, host and port", got) + } + + shared, agentOnly := p.tokenEnvVars("https://gitlab.example.com/group/repo.git", "gitlab-token") + if got := strings.Join(envNames(shared), ","); got != "GITLAB_TOKEN,KELOS_GITLAB_TOKEN_FILE" { + t.Errorf("shared env = %s", got) + } + if shared[0].ValueFrom.SecretKeyRef.Name != "gitlab-token" || shared[0].ValueFrom.SecretKeyRef.Key != "GITLAB_TOKEN" { + t.Errorf("GITLAB_TOKEN must come from gitlab-token/GITLAB_TOKEN, got %+v", shared[0].ValueFrom) + } + if shared[1].Value != GitLabTokenMountPath+"/GITLAB_TOKEN" { + t.Errorf("KELOS_GITLAB_TOKEN_FILE = %q", shared[1].Value) + } + if got := strings.Join(envNames(agentOnly), ","); got != "GLAB_CONFIG_DIR,GLAB_NO_PROMPT,GLAB_CHECK_UPDATE,GLAB_SEND_TELEMETRY" { + t.Errorf("agent-only env = %s", got) + } + for _, v := range append(shared, agentOnly...) { + if strings.HasPrefix(v.Name, "GH_") || v.Name == "GITHUB_TOKEN" { + t.Errorf("gitlab provider must not export GitHub variables, got %s", v.Name) + } + } + + volume := p.tokenVolume("gitlab-token") + if volume.Name != GitLabTokenVolumeName || volume.Secret.SecretName != "gitlab-token" || len(volume.Secret.Items) != 1 || volume.Secret.Items[0].Key != "GITLAB_TOKEN" { + t.Errorf("unexpected token volume: %+v", volume) + } + mount := p.tokenVolumeMount() + if mount.Name != GitLabTokenVolumeName || mount.MountPath != GitLabTokenMountPath || !mount.ReadOnly { + t.Errorf("unexpected token mount: %+v", mount) + } +} + +func TestGitCredentialHelperUsesProviderTokenEnv(t *testing.T) { + helper := gitCredentialHelper(workspaceProviderFor(&kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitLab})) + if !strings.Contains(helper, GitLabTokenMountPath+"/GITLAB_TOKEN") || !strings.Contains(helper, `password=$GITLAB_TOKEN`) { + t.Errorf("gitlab credential helper must read the GitLab token file and env, got %q", helper) + } + if strings.Contains(helper, "GITHUB_TOKEN") { + t.Errorf("gitlab credential helper must not fall back to GITHUB_TOKEN, got %q", helper) + } +} + +func TestWorkspaceSecretTokenError(t *testing.T) { + github := workspaceProviderFor(&kelos.WorkspaceSpec{}) + gitlab := workspaceProviderFor(&kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitLab}) + + if err := workspaceSecretTokenError(github, "app", map[string][]byte{"appID": []byte("1")}); err != nil { + t.Errorf("github secrets are exempt from the token check, got %v", err) + } + if err := workspaceSecretTokenError(gitlab, "gl", map[string][]byte{"GITLAB_TOKEN": []byte("glpat")}); err != nil { + t.Errorf("unexpected error for a valid gitlab secret: %v", err) + } + err := workspaceSecretTokenError(gitlab, "gl", map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}) + if err == nil || !strings.Contains(err.Error(), `secret "gl" has no GITLAB_TOKEN key`) { + t.Errorf("GITHUB_TOKEN must not stand in for GITLAB_TOKEN, got %v", err) + } + if err := workspaceSecretTokenError(gitlab, "gl", map[string][]byte{"GITLAB_TOKEN": []byte(" \n")}); err == nil { + t.Error("blank GITLAB_TOKEN must be rejected") + } +} + +func TestValidateTaskSpawnerWorkspace(t *testing.T) { + gitlabWS := &kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitLab, SecretRef: &kelos.SecretReference{Name: "gl"}} + githubWS := &kelos.WorkspaceSpec{SecretRef: &kelos.SecretReference{Name: "gh"}} + gitlabData := map[string][]byte{"GITLAB_TOKEN": []byte("glpat")} + + tests := []struct { + name string + when kelos.When + workspace *kelos.WorkspaceSpec + data map[string][]byte + wantErr string + }{ + {name: "gitlab source with gitlab workspace", when: kelos.When{GitLab: &kelos.GitLab{}}, workspace: gitlabWS, data: gitlabData}, + {name: "gitlab webhook with gitlab workspace", when: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{}}, workspace: gitlabWS, data: gitlabData}, + {name: "github issues with github workspace", when: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, workspace: githubWS, data: map[string][]byte{"GITHUB_TOKEN": []byte("ghp")}}, + {name: "github app secret without token key", when: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, workspace: githubWS, data: map[string][]byte{"appID": []byte("1")}}, + {name: "cron works with any provider", when: kelos.When{Cron: &kelos.Cron{Schedule: "@hourly"}}, workspace: gitlabWS, data: gitlabData}, + {name: "jira with gitlab workspace missing token", when: kelos.When{Jira: &kelos.Jira{}}, workspace: gitlabWS, data: map[string][]byte{}, wantErr: "has no GITLAB_TOKEN key"}, + {name: "gitlab source with github workspace", when: kelos.When{GitLab: &kelos.GitLab{}}, workspace: githubWS, data: map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}, wantErr: "requires a Workspace with provider gitlab, but the Workspace provider is github"}, + {name: "github source with gitlab workspace", when: kelos.When{GitHubPullRequests: &kelos.GitHubPullRequests{}}, workspace: gitlabWS, data: gitlabData, wantErr: "requires a Workspace with provider github, but the Workspace provider is gitlab"}, + {name: "gitlab secret under GITHUB_TOKEN key", when: kelos.When{GitLab: &kelos.GitLab{}}, workspace: gitlabWS, data: map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}, wantErr: `secret "gl" has no GITLAB_TOKEN key`}, + {name: "gitlab source without workspace secret", when: kelos.When{GitLab: &kelos.GitLab{}}, workspace: &kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitLab}, wantErr: "reference a Secret with a GITLAB_TOKEN key"}, + {name: "nil workspace", when: kelos.When{GitLab: &kelos.GitLab{}}, workspace: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := &kelos.TaskSpawner{Spec: kelos.TaskSpawnerSpec{When: tt.when}} + err := validateTaskSpawnerWorkspace(ts, tt.workspace, tt.data) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + if _, ok := err.(*workspaceValidationError); !ok { + t.Fatalf("error must be a *workspaceValidationError so the spawner is marked Failed, got %T", err) + } + }) + } +} diff --git a/internal/conversion/taskspawner.go b/internal/conversion/taskspawner.go index 57e610a62..10408d548 100644 --- a/internal/conversion/taskspawner.go +++ b/internal/conversion/taskspawner.go @@ -3,6 +3,7 @@ package conversion import ( "context" "encoding/json" + "fmt" v1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" v1alpha2 "github.com/kelos-dev/kelos/api/v1alpha2" @@ -36,6 +37,17 @@ const preservedGitHubCommentsReportingAnnotation = "kelos.dev/v1alpha2-github-co // across a v1alpha1 round-trip without exposing the capability in v1alpha1. const preservedWebhookGatewayRefsAnnotation = "kelos.dev/v1alpha2-webhook-gateway-refs" +// preservedGitLabSourcesAnnotation carries spec.when.gitlab and +// spec.when.gitlabWebhook (v1alpha2-only sources) across a v1alpha1 +// round-trip. They are restored only when the v1alpha1 spec selects no other +// source, so a client that deliberately switches source wins. +const preservedGitLabSourcesAnnotation = "kelos.dev/v1alpha2-gitlab-sources" + +type preservedGitLabSources struct { + GitLab *v1alpha2.GitLab `json:"gitlab,omitempty"` + GitLabWebhook *v1alpha2.GitLabWebhook `json:"gitlabWebhook,omitempty"` +} + type preservedWebhookGatewayRefs struct { GitHub *v1alpha2.GatewayReference `json:"github,omitempty"` Linear *v1alpha2.GatewayReference `json:"linear,omitempty"` @@ -76,6 +88,10 @@ func taskSpawnerToHub(_ context.Context, src *v1alpha1.TaskSpawner, dst *v1alpha deleteAnnotation(dst.Annotations, preservedGitHubCommentsReportingAnnotation) restorePreservedWebhookGatewayRefs(src.Annotations, &dst.Spec.When) deleteAnnotation(dst.Annotations, preservedWebhookGatewayRefsAnnotation) + if err := restorePreservedGitLabSources(src.Annotations, &dst.Spec.When); err != nil { + return err + } + deleteAnnotation(dst.Annotations, preservedGitLabSourcesAnnotation) return nil } @@ -101,6 +117,9 @@ func taskSpawnerFromHub(_ context.Context, src *v1alpha2.TaskSpawner, dst *v1alp if err := setPreservedWebhookGatewayRefs(dst, src.Spec.When); err != nil { return err } + if err := setPreservedGitLabSources(dst, src.Spec.When); err != nil { + return err + } return convertViaJSON(&src.Status, &dst.Status) } @@ -150,6 +169,43 @@ func restorePreservedWebhookGatewayRefs(annotations map[string]string, when *v1a } } +func setPreservedGitLabSources(dst *v1alpha1.TaskSpawner, when v1alpha2.When) error { + if when.GitLab == nil && when.GitLabWebhook == nil { + deleteAnnotation(dst.Annotations, preservedGitLabSourcesAnnotation) + return nil + } + data, err := json.Marshal(preservedGitLabSources{GitLab: when.GitLab, GitLabWebhook: when.GitLabWebhook}) + if err != nil { + return err + } + if dst.Annotations == nil { + dst.Annotations = map[string]string{} + } + dst.Annotations[preservedGitLabSourcesAnnotation] = string(data) + return nil +} + +func restorePreservedGitLabSources(annotations map[string]string, when *v1alpha2.When) error { + raw, ok := annotations[preservedGitLabSourcesAnnotation] + if !ok || raw == "" || whenHasSource(*when) { + return nil + } + var preserved preservedGitLabSources + if err := json.Unmarshal([]byte(raw), &preserved); err != nil { + return fmt.Errorf("decoding %s annotation: %w", preservedGitLabSourcesAnnotation, err) + } + when.GitLab = preserved.GitLab + when.GitLabWebhook = preserved.GitLabWebhook + return nil +} + +func whenHasSource(when v1alpha2.When) bool { + return when.GitHubIssues != nil || when.GitHubPullRequests != nil || when.Cron != nil || + when.Jira != nil || when.GitLab != nil || when.GitHubWebhook != nil || + when.LinearWebhook != nil || when.GitLabWebhook != nil || when.GenericWebhook != nil || + when.Slack != nil +} + func setPreservedNameTemplateAnnotation(dst *v1alpha1.TaskSpawner, nameTemplate string) { if nameTemplate == "" { deleteAnnotation(dst.Annotations, preservedNameTemplateAnnotation) diff --git a/internal/conversion/taskspawner_test.go b/internal/conversion/taskspawner_test.go index af6895b84..7933ff77e 100644 --- a/internal/conversion/taskspawner_test.go +++ b/internal/conversion/taskspawner_test.go @@ -2,6 +2,7 @@ package conversion import ( "context" + "reflect" "testing" corev1 "k8s.io/api/core/v1" @@ -784,6 +785,92 @@ func TestTaskSpawnerFromHub_NoContextGitHubAppAuthOmitsAnnotation(t *testing.T) } } +func TestTaskSpawnerConvert_GitLabSourcesRoundTrip(t *testing.T) { + tests := []struct { + name string + when v1alpha2.When + }{ + { + name: "gitlab", + when: v1alpha2.When{GitLab: &v1alpha2.GitLab{ + Project: "group/repo", + Types: []string{"issues", "mergeRequests"}, + Labels: []string{"kelos"}, + PipelineStatus: "failed", + }}, + }, + { + name: "gitlabWebhook", + when: v1alpha2.When{GitLabWebhook: &v1alpha2.GitLabWebhook{ + Events: []string{"note"}, + Project: "group/repo", + GatewayRef: &v1alpha2.GatewayReference{Name: "gitlab-gateway"}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hub := &v1alpha2.TaskSpawner{Spec: v1alpha2.TaskSpawnerSpec{When: tt.when}} + spoke := &v1alpha1.TaskSpawner{} + if err := taskSpawnerFromHub(context.Background(), hub, spoke); err != nil { + t.Fatalf("taskSpawnerFromHub() error = %v", err) + } + if spoke.Annotations[preservedGitLabSourcesAnnotation] == "" { + t.Fatal("GitLab source preservation annotation is empty") + } + + back := &v1alpha2.TaskSpawner{} + if err := taskSpawnerToHub(context.Background(), spoke, back); err != nil { + t.Fatalf("taskSpawnerToHub() error = %v", err) + } + if !reflect.DeepEqual(back.Spec.When, tt.when) { + t.Fatalf("round-tripped when = %+v, want %+v", back.Spec.When, tt.when) + } + if _, ok := back.Annotations[preservedGitLabSourcesAnnotation]; ok { + t.Fatal("preservation annotation remained on hub") + } + }) + } +} + +func TestTaskSpawnerToHub_EditedV1Alpha1SourceReplacesPreservedGitLab(t *testing.T) { + hub := &v1alpha2.TaskSpawner{Spec: v1alpha2.TaskSpawnerSpec{When: v1alpha2.When{ + GitLab: &v1alpha2.GitLab{Project: "group/repo"}, + }}} + spoke := &v1alpha1.TaskSpawner{} + if err := taskSpawnerFromHub(context.Background(), hub, spoke); err != nil { + t.Fatalf("taskSpawnerFromHub() error = %v", err) + } + spoke.Spec.When.Cron = &v1alpha1.Cron{Schedule: "@hourly"} + + back := &v1alpha2.TaskSpawner{} + if err := taskSpawnerToHub(context.Background(), spoke, back); err != nil { + t.Fatalf("taskSpawnerToHub() error = %v", err) + } + if back.Spec.When.GitLab != nil { + t.Fatalf("preserved GitLab source must not override an explicit v1alpha1 source, got %+v", back.Spec.When.GitLab) + } + if back.Spec.When.Cron == nil || back.Spec.When.Cron.Schedule != "@hourly" { + t.Fatalf("expected cron source to survive, got %+v", back.Spec.When.Cron) + } +} + +func TestTaskSpawnerFromHub_NoGitLabSourceOmitsAnnotation(t *testing.T) { + hub := &v1alpha2.TaskSpawner{Spec: v1alpha2.TaskSpawnerSpec{When: v1alpha2.When{ + Cron: &v1alpha2.Cron{Schedule: "@hourly"}, + }}} + spoke := &v1alpha1.TaskSpawner{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + preservedGitLabSourcesAnnotation: `{"gitlab":{"project":"stale"}}`, + }}} + if err := taskSpawnerFromHub(context.Background(), hub, spoke); err != nil { + t.Fatalf("taskSpawnerFromHub() error = %v", err) + } + if _, ok := spoke.Annotations[preservedGitLabSourcesAnnotation]; ok { + t.Fatal("stale GitLab preservation annotation must be removed when the hub has no GitLab source") + } +} + func TestTaskSpawnerFromHub_NoNameTemplateOmitsAnnotation(t *testing.T) { hub := &v1alpha2.TaskSpawner{ ObjectMeta: metav1.ObjectMeta{Name: "responder", Namespace: "default"}, diff --git a/internal/conversion/workspace.go b/internal/conversion/workspace.go index effa6fcb9..965489bf0 100644 --- a/internal/conversion/workspace.go +++ b/internal/conversion/workspace.go @@ -7,12 +7,36 @@ import ( v1alpha2 "github.com/kelos-dev/kelos/api/v1alpha2" ) +// preservedWorkspaceProviderAnnotation carries spec.provider (a v1alpha2-only +// field) across a v1alpha1 round-trip so a client that reads and writes the +// Workspace through v1alpha1 does not silently turn a GitLab workspace back +// into a GitHub one. +const preservedWorkspaceProviderAnnotation = "kelos.dev/v1alpha2-workspace-provider" + func workspaceToHub(_ context.Context, src *v1alpha1.Workspace, dst *v1alpha2.Workspace) error { - dst.ObjectMeta = src.ObjectMeta - return convertViaJSON(&src.Spec, &dst.Spec) + src.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) + if err := convertViaJSON(&src.Spec, &dst.Spec); err != nil { + return err + } + if provider := src.Annotations[preservedWorkspaceProviderAnnotation]; provider != "" { + dst.Spec.Provider = provider + } + deleteAnnotation(dst.Annotations, preservedWorkspaceProviderAnnotation) + return nil } func workspaceFromHub(_ context.Context, src *v1alpha2.Workspace, dst *v1alpha1.Workspace) error { - dst.ObjectMeta = src.ObjectMeta - return convertViaJSON(&src.Spec, &dst.Spec) + src.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) + if err := convertViaJSON(&src.Spec, &dst.Spec); err != nil { + return err + } + if src.Spec.Provider == "" || src.Spec.Provider == v1alpha2.WorkspaceProviderGitHub { + deleteAnnotation(dst.Annotations, preservedWorkspaceProviderAnnotation) + return nil + } + if dst.Annotations == nil { + dst.Annotations = map[string]string{} + } + dst.Annotations[preservedWorkspaceProviderAnnotation] = src.Spec.Provider + return nil } diff --git a/internal/conversion/workspace_test.go b/internal/conversion/workspace_test.go new file mode 100644 index 000000000..b9bed287a --- /dev/null +++ b/internal/conversion/workspace_test.go @@ -0,0 +1,72 @@ +package conversion + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + v1alpha2 "github.com/kelos-dev/kelos/api/v1alpha2" +) + +func TestWorkspaceConvert_ProviderRoundTrips(t *testing.T) { + hub := &v1alpha2.Workspace{Spec: v1alpha2.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/repo.git", + Provider: v1alpha2.WorkspaceProviderGitLab, + SecretRef: &v1alpha2.SecretReference{Name: "gitlab-token"}, + }} + spoke := &v1alpha1.Workspace{} + if err := workspaceFromHub(context.Background(), hub, spoke); err != nil { + t.Fatalf("workspaceFromHub() error = %v", err) + } + if got := spoke.Annotations[preservedWorkspaceProviderAnnotation]; got != v1alpha2.WorkspaceProviderGitLab { + t.Fatalf("preservation annotation = %q, want %q", got, v1alpha2.WorkspaceProviderGitLab) + } + if hub.Annotations != nil { + t.Fatal("workspaceFromHub() must not mutate the hub annotations") + } + + back := &v1alpha2.Workspace{} + if err := workspaceToHub(context.Background(), spoke, back); err != nil { + t.Fatalf("workspaceToHub() error = %v", err) + } + if back.Spec.Provider != v1alpha2.WorkspaceProviderGitLab { + t.Fatalf("round-tripped provider = %q, want gitlab", back.Spec.Provider) + } + if back.Spec.Repo != hub.Spec.Repo || back.Spec.SecretRef == nil || back.Spec.SecretRef.Name != "gitlab-token" { + t.Fatalf("round-tripped spec = %+v, want repo and secretRef preserved", back.Spec) + } + if _, ok := back.Annotations[preservedWorkspaceProviderAnnotation]; ok { + t.Fatal("preservation annotation remained on hub") + } +} + +func TestWorkspaceFromHub_GitHubProviderOmitsAnnotation(t *testing.T) { + for _, provider := range []string{"", v1alpha2.WorkspaceProviderGitHub} { + hub := &v1alpha2.Workspace{Spec: v1alpha2.WorkspaceSpec{ + Repo: "https://github.com/org/repo.git", + Provider: provider, + }} + spoke := &v1alpha1.Workspace{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + preservedWorkspaceProviderAnnotation: "gitlab", + }}} + if err := workspaceFromHub(context.Background(), hub, spoke); err != nil { + t.Fatalf("workspaceFromHub() error = %v", err) + } + if _, ok := spoke.Annotations[preservedWorkspaceProviderAnnotation]; ok { + t.Fatalf("provider %q must not leave a stale preservation annotation", provider) + } + } +} + +func TestWorkspaceToHub_WithoutAnnotationLeavesProviderEmpty(t *testing.T) { + spoke := &v1alpha1.Workspace{Spec: v1alpha1.WorkspaceSpec{Repo: "https://github.com/org/repo.git"}} + hub := &v1alpha2.Workspace{} + if err := workspaceToHub(context.Background(), spoke, hub); err != nil { + t.Fatalf("workspaceToHub() error = %v", err) + } + if hub.Spec.Provider != "" { + t.Fatalf("provider = %q, want empty so the CRD default applies", hub.Spec.Provider) + } +} diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml index d184e4327..e83d450f2 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml @@ -23178,6 +23178,287 @@ spec: && self.filters.exists(f, f.event == ''issue_comment'') && self.filters.all(f, f.event != ''issue_comment'' || (has(f.commentOn) && f.commentOn == ''PullRequest'')))' + gitlab: + description: GitLab discovers issues and merge requests from a + GitLab project. + properties: + baseUrl: + description: |- + BaseURL overrides the GitLab instance URL used for API calls (for + example "https://gitlab.example.com" or an in-cluster service URL). + When empty, the scheme and host of the workspace repo URL are used. + pattern: ^https?://.+ + type: string + commentPolicy: + description: CommentPolicy configures comment-based workflow + control. + properties: + allowedUsers: + description: |- + AllowedUsers restricts comment control to specific GitLab usernames. + When empty, commands from any user are honored. + items: + type: string + type: array + excludeComments: + description: |- + ExcludeComments blocks items whose most recent matching command is an + exclude command. When combined with TriggerComment, the most recent + matching command wins. + items: + type: string + type: array + triggerComment: + description: |- + TriggerComment requires a matching command for the item to be included. + When set alone, only items with a matching command are discovered. + type: string + type: object + excludeLabels: + description: ExcludeLabels filters out items that have any + of these labels (client-side). + items: + type: string + type: array + labels: + description: Labels filters items by labels; an item must + carry all of them. + items: + type: string + type: array + pipelineStatus: + default: any + description: |- + PipelineStatus filters merge requests by the status of their head + pipeline. A newer pipeline finishing in the selected status retriggers + completed Tasks. "any" does not gate discovery. Issues are not affected. + enum: + - success + - failed + - running + - pending + - canceled + - any + type: string + pollInterval: + description: |- + PollInterval is how often this source is polled (e.g., "30s", "5m"). + When empty, a default of 5m is used. + type: string + project: + description: |- + Project overrides the project to poll as a full path + ("group/subgroup/project"). When empty, the project path is derived + from the workspace repo URL. + type: string + reporting: + description: |- + Reporting configures status reporting back to the originating GitLab + issue or merge request. + properties: + comments: + description: |- + Comments configures task status notes on the originating issue or + merge request. When nil, no notes are posted. + properties: + mode: + default: PerTask + description: |- + Mode controls whether notes are created per Task or reused across + Tasks from the same TaskSpawner and originating issue or merge request. + Defaults to PerTask. + enum: + - PerTask + - Sticky + type: string + type: object + type: object + reviewState: + default: any + description: |- + ReviewState filters merge requests by review outcome: "approved" keeps + merge requests with at least one approval, "changes_requested" keeps + merge requests where a reviewer requested changes, and "any" does not + gate discovery. Issues are not affected. + enum: + - approved + - changes_requested + - any + type: string + state: + default: opened + description: State filters items by state (opened, closed, + all). Defaults to opened. + enum: + - opened + - closed + - all + type: string + types: + default: + - issues + description: |- + Types specifies which item types to discover: "issues", + "mergeRequests", or both. + items: + type: string + type: array + type: object + gitlabWebhook: + description: GitLabWebhook triggers task spawning on GitLab webhook + events. + properties: + events: + description: |- + Events is the list of GitLab event kinds to listen for, matching the + payload object_kind: "issue", "merge_request", "note", "pipeline", + "push", or "tag_push". + items: + type: string + minItems: 1 + type: array + excludeAuthors: + description: |- + ExcludeAuthors excludes events triggered by any of these GitLab usernames. + This is applied before filter evaluation. + items: + type: string + type: array + filters: + description: |- + Filters refine which events match. If multiple filters apply to the same + event kind, any matching filter accepts the event (OR semantics). + If empty, all events in the Events list match. + items: + description: GitLabWebhookFilter defines filtering criteria + for a GitLab webhook event kind. + properties: + action: + description: |- + Action filters issue and merge_request events by the payload action + (e.g., "open", "update", "close", "reopen", "approved", "unapproved", "merge"). + type: string + author: + description: Author filters by the username of the user + who triggered the event. + type: string + bodyPattern: + description: BodyPattern requires the note body to match + a Go re2 regular expression. + type: string + branch: + description: |- + Branch filters merge_request events by source branch, and push and + pipeline events by branch name (exact match or glob). + type: string + draft: + description: Draft filters merge_request events by draft + status. + type: boolean + event: + description: Event is the GitLab event kind this filter + applies to. + enum: + - issue + - merge_request + - note + - pipeline + - push + - tag_push + type: string + excludeAuthors: + description: ExcludeAuthors excludes events triggered + by any of these usernames. + items: + type: string + type: array + excludeBodyPatterns: + description: |- + ExcludeBodyPatterns excludes note events whose body matches any of these + Go re2 regular expressions. + items: + type: string + type: array + excludeLabels: + description: ExcludeLabels excludes issues or merge + requests with any of these labels. + items: + type: string + type: array + labels: + description: |- + Labels requires the issue or merge request to have all of these labels. + For note events the labels of the commented issue or merge request are used. + items: + type: string + type: array + noteOn: + description: |- + NoteOn scopes note events to comments on a specific subject. Omit to + match notes on any subject. + enum: + - Issue + - MergeRequest + - Commit + - Snippet + type: string + state: + description: |- + State filters issue and merge_request events by state + ("opened", "closed", "merged", "locked"). + type: string + status: + description: |- + Status filters pipeline events by pipeline status + (e.g., "success", "failed", "canceled", "running", "pending"). + type: string + required: + - event + type: object + type: array + gatewayRef: + description: |- + GatewayRef binds this source to a WebhookGateway in the same namespace whose + spec.gitlab field is set. The per-source webhook server ignores this spawner. + properties: + name: + description: Name is the name of the WebhookGateway resource. + minLength: 1 + type: string + required: + - name + type: object + project: + description: |- + Project restricts deliveries to one project by its full path + ("group/subgroup/project"). When empty, events from any project are accepted. + type: string + reporting: + description: |- + Reporting configures status notes on the originating GitLab issue or + merge request. Requires a GitLab token on the webhook server + (GITLAB_TOKEN) or on the bound WebhookGateway (spec.gitlab.credentialsRef). + properties: + comments: + description: |- + Comments configures task status notes on the originating issue or + merge request. When nil, no notes are posted. + properties: + mode: + default: PerTask + description: |- + Mode controls whether notes are created per Task or reused across + Tasks from the same TaskSpawner and originating issue or merge request. + Defaults to PerTask. + enum: + - PerTask + - Sticky + type: string + type: object + type: object + required: + - events + type: object jira: description: Jira discovers issues from a Jira project. properties: @@ -23478,13 +23759,14 @@ spec: type: object x-kubernetes-validations: - message: a workspace source is required when using githubIssues, githubPullRequests, - githubWebhook, or linearWebhook source (set taskTemplate.workspaceRef, - taskTemplate.worker.workspaceRef, or taskTemplate.workerPoolRef — - a pool satisfies this because it carries its own workspace) + githubWebhook, linearWebhook, gitlab, or gitlabWebhook source (set + taskTemplate.workspaceRef, taskTemplate.worker.workspaceRef, or taskTemplate.workerPoolRef + — a pool satisfies this because it carries its own workspace) rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests) - || has(self.when.githubWebhook) || has(self.when.linearWebhook)) || - has(self.taskTemplate.workspaceRef) || (has(self.taskTemplate.worker) - && has(self.taskTemplate.worker.workspaceRef)) || has(self.taskTemplate.workerPoolRef)' + || has(self.when.githubWebhook) || has(self.when.linearWebhook) || + has(self.when.gitlab) || has(self.when.gitlabWebhook)) || has(self.taskTemplate.workspaceRef) + || (has(self.taskTemplate.worker) && has(self.taskTemplate.worker.workspaceRef)) + || has(self.taskTemplate.workerPoolRef)' - message: inline task templates require taskTemplate credentials or spec.credentials rule: has(self.taskTemplate.workerPoolRef) || (has(self.taskTemplate.worker) && (has(self.taskTemplate.worker.credentials) || has(self.credentials))) diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml index 5295dd6c6..63131a102 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml @@ -54,8 +54,8 @@ spec: spec: description: |- WebhookGatewaySpec defines the desired state of a WebhookGateway. Exactly one - of GitHub, Linear, or Generic must be set; the field that is present selects - the webhook source and carries its provider-specific configuration. + of GitHub, Linear, GitLab, or Generic must be set; the field that is present + selects the webhook source and carries its provider-specific configuration. properties: generic: description: Generic configures a gateway for arbitrary HTTP POST @@ -97,6 +97,44 @@ spec: required: - secretRef type: object + gitlab: + description: GitLab configures a gateway for GitLab webhook deliveries. + properties: + apiBaseURL: + description: |- + APIBaseURL is the GitLab instance URL used for status reporting (for + example "https://gitlab.example.com" or an in-cluster service URL). When + empty, the instance URL is taken from the originating webhook payload. + pattern: ^https?://.+ + type: string + credentialsRef: + description: |- + CredentialsRef references a Secret holding a GitLab access token under + the GITLAB_TOKEN key. Required for status reporting on Tasks created + through this gateway. + properties: + name: + description: Name is the name of the secret. + minLength: 1 + type: string + required: + - name + type: object + secretRef: + description: |- + SecretRef references a Secret holding the webhook secret token under the + "webhook-secret" key. + properties: + name: + description: Name is the name of the secret. + minLength: 1 + type: string + required: + - name + type: object + required: + - secretRef + type: object linear: description: Linear configures a gateway for Linear webhook deliveries. properties: @@ -117,8 +155,8 @@ spec: type: object type: object x-kubernetes-validations: - - message: exactly one of github, linear, or generic must be set - rule: (has(self.github)?1:0)+(has(self.linear)?1:0)+(has(self.generic)?1:0) + - message: exactly one of github, linear, gitlab, or generic must be set + rule: (has(self.github)?1:0)+(has(self.linear)?1:0)+(has(self.gitlab)?1:0)+(has(self.generic)?1:0) == 1 status: description: WebhookGatewayStatus defines the observed state of a WebhookGateway. diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml index 5f580b1e1..0b75de7d8 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml @@ -208,6 +208,17 @@ spec: description: GHProxy configures and enables the workspace-scoped ghproxy when set. type: object + provider: + default: github + description: |- + Provider selects the git hosting provider. It determines the key read + from the SecretRef Secret (GITHUB_TOKEN for github, GITLAB_TOKEN for + gitlab), the credentials exported to agent containers, and which CLI + (gh or glab) is preconfigured. + enum: + - github + - gitlab + type: string ref: description: |- Ref is the git reference to checkout (branch, tag, or commit SHA). @@ -246,8 +257,9 @@ spec: type: string secretRef: description: |- - SecretRef references a Secret containing a GITHUB_TOKEN key for git - authentication and GitHub CLI (gh) operations. + SecretRef references a Secret containing the Provider's token key + (GITHUB_TOKEN for github, GITLAB_TOKEN for gitlab) for git + authentication and CLI operations. properties: name: description: Name is the name of the secret. @@ -280,6 +292,10 @@ spec: required: - repo type: object + x-kubernetes-validations: + - message: ghproxy is only supported when provider is github + rule: '!has(self.ghproxy) || !has(self.provider) || self.provider == + ''github''' type: object served: true storage: true diff --git a/internal/manifests/charts/kelos/templates/rbac.yaml b/internal/manifests/charts/kelos/templates/rbac.yaml index 9afd6d82e..dfe38d3bd 100644 --- a/internal/manifests/charts/kelos/templates/rbac.yaml +++ b/internal/manifests/charts/kelos/templates/rbac.yaml @@ -376,7 +376,7 @@ subjects: - kind: ServiceAccount name: kelos-controller namespace: kelos-system -{{- $sourceWebhooksEnabled := or .Values.webhookServer.sources.github.enabled .Values.webhookServer.sources.linear.enabled .Values.webhookServer.sources.generic.enabled }} +{{- $sourceWebhooksEnabled := or .Values.webhookServer.sources.github.enabled .Values.webhookServer.sources.linear.enabled .Values.webhookServer.sources.gitlab.enabled .Values.webhookServer.sources.generic.enabled }} {{- $gatewayServerEnabled := .Values.webhookServer.gatewayServer.enabled }} {{- $gatewayServiceAccountName := .Values.webhookServer.gatewayServer.serviceAccountName }} {{- if or $sourceWebhooksEnabled $gatewayServerEnabled }} diff --git a/internal/manifests/charts/kelos/templates/serviceaccount.yaml b/internal/manifests/charts/kelos/templates/serviceaccount.yaml index 07718f647..4bd1195d3 100644 --- a/internal/manifests/charts/kelos/templates/serviceaccount.yaml +++ b/internal/manifests/charts/kelos/templates/serviceaccount.yaml @@ -1,4 +1,4 @@ -{{- $sourceWebhooksEnabled := or .Values.webhookServer.sources.github.enabled .Values.webhookServer.sources.linear.enabled .Values.webhookServer.sources.generic.enabled }} +{{- $sourceWebhooksEnabled := or .Values.webhookServer.sources.github.enabled .Values.webhookServer.sources.linear.enabled .Values.webhookServer.sources.gitlab.enabled .Values.webhookServer.sources.generic.enabled }} {{- $gatewayServerEnabled := .Values.webhookServer.gatewayServer.enabled }} {{- $gatewayServiceAccountName := .Values.webhookServer.gatewayServer.serviceAccountName }} --- diff --git a/internal/manifests/charts/kelos/templates/webhook-gateway.yaml b/internal/manifests/charts/kelos/templates/webhook-gateway.yaml index b85fefd65..1af59a22e 100644 --- a/internal/manifests/charts/kelos/templates/webhook-gateway.yaml +++ b/internal/manifests/charts/kelos/templates/webhook-gateway.yaml @@ -1,9 +1,10 @@ {{- if .Values.webhookServer.gateway.enabled }} {{- $githubEnabled := .Values.webhookServer.sources.github.enabled }} {{- $linearEnabled := .Values.webhookServer.sources.linear.enabled }} +{{- $gitlabEnabled := .Values.webhookServer.sources.gitlab.enabled }} {{- $genericEnabled := .Values.webhookServer.sources.generic.enabled }} {{- $gatewayServerEnabled := .Values.webhookServer.gatewayServer.enabled }} -{{- if or $githubEnabled $linearEnabled $genericEnabled $gatewayServerEnabled }} +{{- if or $githubEnabled $linearEnabled $gitlabEnabled $genericEnabled $gatewayServerEnabled }} --- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway @@ -84,13 +85,23 @@ spec: - name: kelos-webhook-linear port: 8443 {{- end }} + {{- if $gitlabEnabled }} + - matches: + - path: + type: PathPrefix + value: /webhook/gitlab + backendRefs: + - name: kelos-webhook-gitlab + port: 8443 + {{- end }} {{- /* The gateway server (/webhook//) and the per-source generic server (/webhook/) both live under the /webhook/ prefix, so a single PathPrefix cannot route to both. When the gateway server is enabled it takes /webhook/, making the per-source generic server unreachable via this - route. The github/linear servers use the more specific /webhook/github and - /webhook/linear prefixes and remain reachable. + route. The github/linear/gitlab servers use the more specific + /webhook/github, /webhook/linear, and /webhook/gitlab prefixes and remain + reachable. */}} {{- if $gatewayServerEnabled }} - matches: diff --git a/internal/manifests/charts/kelos/templates/webhook-ingress.yaml b/internal/manifests/charts/kelos/templates/webhook-ingress.yaml index 773b82a89..6181e57ac 100644 --- a/internal/manifests/charts/kelos/templates/webhook-ingress.yaml +++ b/internal/manifests/charts/kelos/templates/webhook-ingress.yaml @@ -1,8 +1,9 @@ {{- if .Values.webhookServer.ingress.enabled }} {{- $githubEnabled := .Values.webhookServer.sources.github.enabled }} {{- $linearEnabled := .Values.webhookServer.sources.linear.enabled }} +{{- $gitlabEnabled := .Values.webhookServer.sources.gitlab.enabled }} {{- $genericEnabled := .Values.webhookServer.sources.generic.enabled }} -{{- if or $githubEnabled $linearEnabled $genericEnabled }} +{{- if or $githubEnabled $linearEnabled $gitlabEnabled $genericEnabled }} --- apiVersion: networking.k8s.io/v1 kind: Ingress @@ -50,6 +51,15 @@ spec: port: number: 8443 {{- end }} + {{- if $gitlabEnabled }} + - path: /webhook/gitlab + pathType: Prefix + backend: + service: + name: kelos-webhook-gitlab + port: + number: 8443 + {{- end }} {{- if $genericEnabled }} - path: /webhook/ pathType: Prefix diff --git a/internal/manifests/charts/kelos/templates/webhook-server.yaml b/internal/manifests/charts/kelos/templates/webhook-server.yaml index 2f8c0aa83..a50e0b953 100644 --- a/internal/manifests/charts/kelos/templates/webhook-server.yaml +++ b/internal/manifests/charts/kelos/templates/webhook-server.yaml @@ -1,5 +1,5 @@ {{- $allowedServiceTypes := list "ClusterIP" "LoadBalancer" "NodePort" }} -{{- range $source := list "github" "linear" "generic" }} +{{- range $source := list "github" "linear" "gitlab" "generic" }} {{- $sourceCfg := index $.Values.webhookServer.sources $source }} {{- if $sourceCfg.enabled }} {{- if not (has $sourceCfg.service.type $allowedServiceTypes) }} @@ -257,6 +257,116 @@ spec: app.kubernetes.io/component: webhook-linear {{- end }} +{{- if .Values.webhookServer.sources.gitlab.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-gitlab + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-gitlab +spec: + replicas: {{ .Values.webhookServer.sources.gitlab.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-gitlab + template: + metadata: + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-gitlab + spec: + serviceAccountName: kelos-webhook + securityContext: + runAsNonRoot: true + containers: + - name: webhook-server + image: {{ .Values.webhookServer.image }}{{- if .Values.image.tag }}:{{ .Values.image.tag }}{{- end }} + {{- if .Values.image.pullPolicy }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- end }} + args: + - --source=gitlab + - --webhook-bind-address=:8443 + - --metrics-bind-address=:8080 + - --health-probe-bind-address=:8081 + env: + - name: WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.webhookServer.sources.gitlab.secretName }} + key: WEBHOOK_SECRET + {{- if .Values.webhookServer.sources.gitlab.tokenSecretName }} + - name: GITLAB_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.webhookServer.sources.gitlab.tokenSecretName }} + key: GITLAB_TOKEN + {{- end }} + ports: + - name: webhook + containerPort: 8443 + protocol: TCP + - name: metrics + containerPort: 8080 + protocol: TCP + - name: health + containerPort: 8081 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + {{- if .Values.webhookServer.resources }} + resources: + {{- toYaml .Values.webhookServer.resources | nindent 12 }} + {{- end }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - "ALL" +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-gitlab + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-gitlab +spec: + type: {{ .Values.webhookServer.sources.gitlab.service.type }} + ports: + - name: webhook + port: 8443 + targetPort: webhook + protocol: TCP + {{- if eq .Values.webhookServer.sources.gitlab.service.type "ClusterIP" }} + - name: metrics + port: 8080 + targetPort: metrics + protocol: TCP + {{- end }} + selector: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-gitlab +{{- end }} + {{- if .Values.webhookServer.sources.generic.enabled }} --- apiVersion: apps/v1 diff --git a/internal/manifests/charts/kelos/values.yaml b/internal/manifests/charts/kelos/values.yaml index cf0cd31b1..22d3a91a1 100644 --- a/internal/manifests/charts/kelos/values.yaml +++ b/internal/manifests/charts/kelos/values.yaml @@ -119,6 +119,25 @@ webhookServer: # to avoid externally publishing the metrics endpoint; scrape metrics # via a PodMonitor or a separate ClusterIP Service in that case. type: ClusterIP + gitlab: + enabled: false + replicas: 1 + # Secret with a WEBHOOK_SECRET key holding the GitLab webhook secret token + # (sent by GitLab in the X-Gitlab-Token header). + secretName: "" + # Optional Secret with a GITLAB_TOKEN key (access token with the api + # scope). Enables status notes on the originating issue or merge request + # for spawners that set gitlabWebhook.reporting. + tokenSecretName: "" + service: + # Service type for the webhook endpoint. + # Allowed values: ClusterIP, LoadBalancer, NodePort. + # When type is ClusterIP, the Service also exposes the unauthenticated + # metrics port (8080) for in-cluster scraping. When type is + # LoadBalancer or NodePort, only the webhook port (8443) is exposed + # to avoid externally publishing the metrics endpoint; scrape metrics + # via a PodMonitor or a separate ClusterIP Service in that case. + type: ClusterIP generic: enabled: false replicas: 1 @@ -140,8 +159,8 @@ webhookServer: # carries its own HMAC secret and (for github) API base URL and credentials, # so one deployment can serve github.com plus multiple GitHub Enterprise # instances. It serves TaskSpawners and SessionSpawners that reference a - # WebhookGateway. The github/linear per-source servers can run alongside it - # because they use more specific Gateway-API paths. The gateway server and + # WebhookGateway. The github/linear/gitlab per-source servers can run + # alongside it because they use more specific Gateway-API paths. The gateway server and # generic per-source server both use the /webhook/ prefix, so the generated # Gateway API route can expose only one of them. gatewayServer: diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index e5d8fe6f2..8be2100cd 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -60481,6 +60481,287 @@ spec: && self.filters.exists(f, f.event == ''issue_comment'') && self.filters.all(f, f.event != ''issue_comment'' || (has(f.commentOn) && f.commentOn == ''PullRequest'')))' + gitlab: + description: GitLab discovers issues and merge requests from a + GitLab project. + properties: + baseUrl: + description: |- + BaseURL overrides the GitLab instance URL used for API calls (for + example "https://gitlab.example.com" or an in-cluster service URL). + When empty, the scheme and host of the workspace repo URL are used. + pattern: ^https?://.+ + type: string + commentPolicy: + description: CommentPolicy configures comment-based workflow + control. + properties: + allowedUsers: + description: |- + AllowedUsers restricts comment control to specific GitLab usernames. + When empty, commands from any user are honored. + items: + type: string + type: array + excludeComments: + description: |- + ExcludeComments blocks items whose most recent matching command is an + exclude command. When combined with TriggerComment, the most recent + matching command wins. + items: + type: string + type: array + triggerComment: + description: |- + TriggerComment requires a matching command for the item to be included. + When set alone, only items with a matching command are discovered. + type: string + type: object + excludeLabels: + description: ExcludeLabels filters out items that have any + of these labels (client-side). + items: + type: string + type: array + labels: + description: Labels filters items by labels; an item must + carry all of them. + items: + type: string + type: array + pipelineStatus: + default: any + description: |- + PipelineStatus filters merge requests by the status of their head + pipeline. A newer pipeline finishing in the selected status retriggers + completed Tasks. "any" does not gate discovery. Issues are not affected. + enum: + - success + - failed + - running + - pending + - canceled + - any + type: string + pollInterval: + description: |- + PollInterval is how often this source is polled (e.g., "30s", "5m"). + When empty, a default of 5m is used. + type: string + project: + description: |- + Project overrides the project to poll as a full path + ("group/subgroup/project"). When empty, the project path is derived + from the workspace repo URL. + type: string + reporting: + description: |- + Reporting configures status reporting back to the originating GitLab + issue or merge request. + properties: + comments: + description: |- + Comments configures task status notes on the originating issue or + merge request. When nil, no notes are posted. + properties: + mode: + default: PerTask + description: |- + Mode controls whether notes are created per Task or reused across + Tasks from the same TaskSpawner and originating issue or merge request. + Defaults to PerTask. + enum: + - PerTask + - Sticky + type: string + type: object + type: object + reviewState: + default: any + description: |- + ReviewState filters merge requests by review outcome: "approved" keeps + merge requests with at least one approval, "changes_requested" keeps + merge requests where a reviewer requested changes, and "any" does not + gate discovery. Issues are not affected. + enum: + - approved + - changes_requested + - any + type: string + state: + default: opened + description: State filters items by state (opened, closed, + all). Defaults to opened. + enum: + - opened + - closed + - all + type: string + types: + default: + - issues + description: |- + Types specifies which item types to discover: "issues", + "mergeRequests", or both. + items: + type: string + type: array + type: object + gitlabWebhook: + description: GitLabWebhook triggers task spawning on GitLab webhook + events. + properties: + events: + description: |- + Events is the list of GitLab event kinds to listen for, matching the + payload object_kind: "issue", "merge_request", "note", "pipeline", + "push", or "tag_push". + items: + type: string + minItems: 1 + type: array + excludeAuthors: + description: |- + ExcludeAuthors excludes events triggered by any of these GitLab usernames. + This is applied before filter evaluation. + items: + type: string + type: array + filters: + description: |- + Filters refine which events match. If multiple filters apply to the same + event kind, any matching filter accepts the event (OR semantics). + If empty, all events in the Events list match. + items: + description: GitLabWebhookFilter defines filtering criteria + for a GitLab webhook event kind. + properties: + action: + description: |- + Action filters issue and merge_request events by the payload action + (e.g., "open", "update", "close", "reopen", "approved", "unapproved", "merge"). + type: string + author: + description: Author filters by the username of the user + who triggered the event. + type: string + bodyPattern: + description: BodyPattern requires the note body to match + a Go re2 regular expression. + type: string + branch: + description: |- + Branch filters merge_request events by source branch, and push and + pipeline events by branch name (exact match or glob). + type: string + draft: + description: Draft filters merge_request events by draft + status. + type: boolean + event: + description: Event is the GitLab event kind this filter + applies to. + enum: + - issue + - merge_request + - note + - pipeline + - push + - tag_push + type: string + excludeAuthors: + description: ExcludeAuthors excludes events triggered + by any of these usernames. + items: + type: string + type: array + excludeBodyPatterns: + description: |- + ExcludeBodyPatterns excludes note events whose body matches any of these + Go re2 regular expressions. + items: + type: string + type: array + excludeLabels: + description: ExcludeLabels excludes issues or merge + requests with any of these labels. + items: + type: string + type: array + labels: + description: |- + Labels requires the issue or merge request to have all of these labels. + For note events the labels of the commented issue or merge request are used. + items: + type: string + type: array + noteOn: + description: |- + NoteOn scopes note events to comments on a specific subject. Omit to + match notes on any subject. + enum: + - Issue + - MergeRequest + - Commit + - Snippet + type: string + state: + description: |- + State filters issue and merge_request events by state + ("opened", "closed", "merged", "locked"). + type: string + status: + description: |- + Status filters pipeline events by pipeline status + (e.g., "success", "failed", "canceled", "running", "pending"). + type: string + required: + - event + type: object + type: array + gatewayRef: + description: |- + GatewayRef binds this source to a WebhookGateway in the same namespace whose + spec.gitlab field is set. The per-source webhook server ignores this spawner. + properties: + name: + description: Name is the name of the WebhookGateway resource. + minLength: 1 + type: string + required: + - name + type: object + project: + description: |- + Project restricts deliveries to one project by its full path + ("group/subgroup/project"). When empty, events from any project are accepted. + type: string + reporting: + description: |- + Reporting configures status notes on the originating GitLab issue or + merge request. Requires a GitLab token on the webhook server + (GITLAB_TOKEN) or on the bound WebhookGateway (spec.gitlab.credentialsRef). + properties: + comments: + description: |- + Comments configures task status notes on the originating issue or + merge request. When nil, no notes are posted. + properties: + mode: + default: PerTask + description: |- + Mode controls whether notes are created per Task or reused across + Tasks from the same TaskSpawner and originating issue or merge request. + Defaults to PerTask. + enum: + - PerTask + - Sticky + type: string + type: object + type: object + required: + - events + type: object jira: description: Jira discovers issues from a Jira project. properties: @@ -60781,13 +61062,14 @@ spec: type: object x-kubernetes-validations: - message: a workspace source is required when using githubIssues, githubPullRequests, - githubWebhook, or linearWebhook source (set taskTemplate.workspaceRef, - taskTemplate.worker.workspaceRef, or taskTemplate.workerPoolRef — - a pool satisfies this because it carries its own workspace) + githubWebhook, linearWebhook, gitlab, or gitlabWebhook source (set + taskTemplate.workspaceRef, taskTemplate.worker.workspaceRef, or taskTemplate.workerPoolRef + — a pool satisfies this because it carries its own workspace) rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests) - || has(self.when.githubWebhook) || has(self.when.linearWebhook)) || - has(self.taskTemplate.workspaceRef) || (has(self.taskTemplate.worker) - && has(self.taskTemplate.worker.workspaceRef)) || has(self.taskTemplate.workerPoolRef)' + || has(self.when.githubWebhook) || has(self.when.linearWebhook) || + has(self.when.gitlab) || has(self.when.gitlabWebhook)) || has(self.taskTemplate.workspaceRef) + || (has(self.taskTemplate.worker) && has(self.taskTemplate.worker.workspaceRef)) + || has(self.taskTemplate.workerPoolRef)' - message: inline task templates require taskTemplate credentials or spec.credentials rule: has(self.taskTemplate.workerPoolRef) || (has(self.taskTemplate.worker) && (has(self.taskTemplate.worker.credentials) || has(self.credentials))) @@ -60952,8 +61234,8 @@ spec: spec: description: |- WebhookGatewaySpec defines the desired state of a WebhookGateway. Exactly one - of GitHub, Linear, or Generic must be set; the field that is present selects - the webhook source and carries its provider-specific configuration. + of GitHub, Linear, GitLab, or Generic must be set; the field that is present + selects the webhook source and carries its provider-specific configuration. properties: generic: description: Generic configures a gateway for arbitrary HTTP POST @@ -60995,6 +61277,44 @@ spec: required: - secretRef type: object + gitlab: + description: GitLab configures a gateway for GitLab webhook deliveries. + properties: + apiBaseURL: + description: |- + APIBaseURL is the GitLab instance URL used for status reporting (for + example "https://gitlab.example.com" or an in-cluster service URL). When + empty, the instance URL is taken from the originating webhook payload. + pattern: ^https?://.+ + type: string + credentialsRef: + description: |- + CredentialsRef references a Secret holding a GitLab access token under + the GITLAB_TOKEN key. Required for status reporting on Tasks created + through this gateway. + properties: + name: + description: Name is the name of the secret. + minLength: 1 + type: string + required: + - name + type: object + secretRef: + description: |- + SecretRef references a Secret holding the webhook secret token under the + "webhook-secret" key. + properties: + name: + description: Name is the name of the secret. + minLength: 1 + type: string + required: + - name + type: object + required: + - secretRef + type: object linear: description: Linear configures a gateway for Linear webhook deliveries. properties: @@ -61015,8 +61335,8 @@ spec: type: object type: object x-kubernetes-validations: - - message: exactly one of github, linear, or generic must be set - rule: (has(self.github)?1:0)+(has(self.linear)?1:0)+(has(self.generic)?1:0) + - message: exactly one of github, linear, gitlab, or generic must be set + rule: (has(self.github)?1:0)+(has(self.linear)?1:0)+(has(self.gitlab)?1:0)+(has(self.generic)?1:0) == 1 status: description: WebhookGatewayStatus defines the observed state of a WebhookGateway. @@ -68525,6 +68845,17 @@ spec: description: GHProxy configures and enables the workspace-scoped ghproxy when set. type: object + provider: + default: github + description: |- + Provider selects the git hosting provider. It determines the key read + from the SecretRef Secret (GITHUB_TOKEN for github, GITLAB_TOKEN for + gitlab), the credentials exported to agent containers, and which CLI + (gh or glab) is preconfigured. + enum: + - github + - gitlab + type: string ref: description: |- Ref is the git reference to checkout (branch, tag, or commit SHA). @@ -68563,8 +68894,9 @@ spec: type: string secretRef: description: |- - SecretRef references a Secret containing a GITHUB_TOKEN key for git - authentication and GitHub CLI (gh) operations. + SecretRef references a Secret containing the Provider's token key + (GITHUB_TOKEN for github, GITLAB_TOKEN for gitlab) for git + authentication and CLI operations. properties: name: description: Name is the name of the secret. @@ -68597,6 +68929,10 @@ spec: required: - repo type: object + x-kubernetes-validations: + - message: ghproxy is only supported when provider is github + rule: '!has(self.ghproxy) || !has(self.provider) || self.provider == + ''github''' type: object served: true storage: true diff --git a/internal/reporting/github.go b/internal/reporting/github.go index 908fc965b..1b42ddf73 100644 --- a/internal/reporting/github.go +++ b/internal/reporting/github.go @@ -75,7 +75,7 @@ func (o commentOwner) owns(comment commentResponse) bool { // FindCommentByMarker returns the newest comment containing marker, or zero // when no matching comment exists. -func (r *GitHubReporter) FindCommentByMarker(ctx context.Context, number int, marker string) (int64, error) { +func (r *GitHubReporter) FindCommentByMarker(ctx context.Context, target CommentTarget, marker string) (int64, error) { owner, err := r.commentOwner(ctx) if err != nil { return 0, err @@ -83,7 +83,7 @@ func (r *GitHubReporter) FindCommentByMarker(ctx context.Context, number int, ma var foundID int64 for page := 1; ; page++ { - url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=100&page=%d", r.baseURL(), r.Owner, r.Repo, number, page) + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=100&page=%d", r.baseURL(), r.Owner, r.Repo, target.Number, page) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return 0, fmt.Errorf("creating request: %w", err) @@ -156,8 +156,8 @@ func (r *GitHubReporter) commentOwner(ctx context.Context) (commentOwner, error) // CreateComment creates a comment on a GitHub issue or pull request and returns // the comment ID. -func (r *GitHubReporter) CreateComment(ctx context.Context, number int, body string) (int64, error) { - url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", r.baseURL(), r.Owner, r.Repo, number) +func (r *GitHubReporter) CreateComment(ctx context.Context, target CommentTarget, body string) (int64, error) { + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", r.baseURL(), r.Owner, r.Repo, target.Number) payload, err := json.Marshal(createCommentRequest{Body: body}) if err != nil { @@ -190,7 +190,7 @@ func (r *GitHubReporter) CreateComment(ctx context.Context, number int, body str } // UpdateComment updates an existing GitHub comment by its ID. -func (r *GitHubReporter) UpdateComment(ctx context.Context, commentID int64, body string) error { +func (r *GitHubReporter) UpdateComment(ctx context.Context, _ CommentTarget, commentID int64, body string) error { url := fmt.Sprintf("%s/repos/%s/%s/issues/comments/%s", r.baseURL(), r.Owner, r.Repo, strconv.FormatInt(commentID, 10)) payload, err := json.Marshal(createCommentRequest{Body: body}) diff --git a/internal/reporting/github_test.go b/internal/reporting/github_test.go index 3dc7ad629..a2a2b0a3b 100644 --- a/internal/reporting/github_test.go +++ b/internal/reporting/github_test.go @@ -42,7 +42,7 @@ func TestCreateComment(t *testing.T) { BaseURL: server.URL, } - commentID, err := reporter.CreateComment(context.Background(), 42, "Test comment body") + commentID, err := reporter.CreateComment(context.Background(), CommentTarget{Kind: "issue", Number: 42}, "Test comment body") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -84,7 +84,7 @@ func TestCreateCommentError(t *testing.T) { BaseURL: server.URL, } - _, err := reporter.CreateComment(context.Background(), 1, "body") + _, err := reporter.CreateComment(context.Background(), CommentTarget{Kind: "issue", Number: 1}, "body") if err == nil { t.Fatal("Expected error, got nil") } @@ -117,7 +117,7 @@ func TestUpdateComment(t *testing.T) { BaseURL: server.URL, } - err := reporter.UpdateComment(context.Background(), 12345, "Updated body") + err := reporter.UpdateComment(context.Background(), CommentTarget{Kind: "issue", Number: 42}, 12345, "Updated body") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -147,7 +147,7 @@ func TestUpdateCommentError(t *testing.T) { BaseURL: server.URL, } - err := reporter.UpdateComment(context.Background(), 99999, "body") + err := reporter.UpdateComment(context.Background(), CommentTarget{Kind: "issue", Number: 1}, 99999, "body") if err == nil { t.Fatal("Expected error, got nil") } @@ -177,7 +177,7 @@ func TestFindCommentByMarker(t *testing.T) { defer server.Close() reporter := &GitHubReporter{Owner: "owner", Repo: "repo", Token: "token", BaseURL: server.URL} - commentID, err := reporter.FindCommentByMarker(context.Background(), 42, marker) + commentID, err := reporter.FindCommentByMarker(context.Background(), CommentTarget{Kind: "issue", Number: 42}, marker) if err != nil { t.Fatalf("FindCommentByMarker() error = %v", err) } @@ -203,7 +203,7 @@ func TestFindCommentByMarkerForGitHubApp(t *testing.T) { reporter := &GitHubReporter{ Owner: "owner", Repo: "repo", Token: "token", GitHubAppID: "123", BaseURL: server.URL, } - commentID, err := reporter.FindCommentByMarker(context.Background(), 42, marker) + commentID, err := reporter.FindCommentByMarker(context.Background(), CommentTarget{Kind: "issue", Number: 42}, marker) if err != nil { t.Fatalf("FindCommentByMarker() error = %v", err) } @@ -220,7 +220,7 @@ func TestFindCommentByMarkerError(t *testing.T) { defer server.Close() reporter := &GitHubReporter{Owner: "owner", Repo: "repo", Token: "token", BaseURL: server.URL} - if _, err := reporter.FindCommentByMarker(context.Background(), 42, "marker"); err == nil { + if _, err := reporter.FindCommentByMarker(context.Background(), CommentTarget{Kind: "issue", Number: 42}, "marker"); err == nil { t.Fatal("FindCommentByMarker() error = nil, want an error") } } @@ -241,7 +241,7 @@ func TestCreateCommentNoToken(t *testing.T) { BaseURL: server.URL, } - _, err := reporter.CreateComment(context.Background(), 1, "body") + _, err := reporter.CreateComment(context.Background(), CommentTarget{Kind: "issue", Number: 1}, "body") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -307,7 +307,7 @@ func TestCreateComment_UsesTokenFunc(t *testing.T) { BaseURL: server.URL, } - _, err := reporter.CreateComment(context.Background(), 1, "body") + _, err := reporter.CreateComment(context.Background(), CommentTarget{Kind: "issue", Number: 1}, "body") if err != nil { t.Fatalf("Unexpected error: %v", err) } diff --git a/internal/reporting/gitlab.go b/internal/reporting/gitlab.go new file mode 100644 index 000000000..df33900e9 --- /dev/null +++ b/internal/reporting/gitlab.go @@ -0,0 +1,146 @@ +package reporting + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// SourceKindMergeRequest is the AnnotationSourceKind value for GitLab merge +// requests. Issues and pull requests use "issue" and "pull-request". +const SourceKindMergeRequest = "merge-request" + +// GitLabReporter posts and updates notes on GitLab issues and merge requests. +// TokenFunc, when set, is called on every API request; otherwise Token is used. +type GitLabReporter struct { + // BaseURL is the GitLab instance URL (e.g. "https://gitlab.example.com"). + BaseURL string + // Project is the full project path (e.g. "group/subgroup/project"). + Project string + Token string + TokenFunc func() string + Client *http.Client +} + +type gitlabNote struct { + ID int64 `json:"id"` + Body string `json:"body"` + Author gitlabUser `json:"author"` +} + +type gitlabUser struct { + Username string `json:"username"` +} + +func (r *GitLabReporter) httpClient() *http.Client { + if r.Client != nil { + return r.Client + } + return http.DefaultClient +} + +// notesURL returns the notes collection for the target, e.g. +// https://gitlab.example.com/api/v4/projects/group%2Frepo/issues/42/notes. +func (r *GitLabReporter) notesURL(target CommentTarget) string { + resource := "issues" + if target.Kind == SourceKindMergeRequest { + resource = "merge_requests" + } + return fmt.Sprintf("%s/api/v4/projects/%s/%s/%d/notes", strings.TrimRight(r.BaseURL, "/"), url.PathEscape(r.Project), resource, target.Number) +} + +// FindCommentByMarker returns the newest note authored by the token's user +// that contains marker, or zero when none exists. +func (r *GitLabReporter) FindCommentByMarker(ctx context.Context, target CommentTarget, marker string) (int64, error) { + var me gitlabUser + if _, err := r.do(ctx, http.MethodGet, strings.TrimRight(r.BaseURL, "/")+"/api/v4/user", nil, http.StatusOK, &me); err != nil { + return 0, fmt.Errorf("getting authenticated GitLab user: %w", err) + } + + var foundID int64 + for page := 1; ; page++ { + var notes []gitlabNote + resp, err := r.do(ctx, http.MethodGet, fmt.Sprintf("%s?per_page=100&sort=asc&order_by=created_at&page=%d", r.notesURL(target), page), nil, http.StatusOK, ¬es) + if err != nil { + return 0, fmt.Errorf("listing notes: %w", err) + } + for _, note := range notes { + if strings.Contains(note.Body, marker) && strings.EqualFold(note.Author.Username, me.Username) { + foundID = note.ID + } + } + if resp.Header.Get("X-Next-Page") == "" || len(notes) == 0 { + return foundID, nil + } + } +} + +// CreateComment creates a note on the target and returns the note ID. +func (r *GitLabReporter) CreateComment(ctx context.Context, target CommentTarget, body string) (int64, error) { + var note gitlabNote + if _, err := r.do(ctx, http.MethodPost, r.notesURL(target), map[string]string{"body": body}, http.StatusCreated, ¬e); err != nil { + return 0, fmt.Errorf("posting note: %w", err) + } + return note.ID, nil +} + +// UpdateComment replaces the body of an existing note on the target. +func (r *GitLabReporter) UpdateComment(ctx context.Context, target CommentTarget, commentID int64, body string) error { + if _, err := r.do(ctx, http.MethodPut, fmt.Sprintf("%s/%d", r.notesURL(target), commentID), map[string]string{"body": body}, http.StatusOK, nil); err != nil { + return fmt.Errorf("updating note: %w", err) + } + return nil +} + +func (r *GitLabReporter) resolveToken() string { + if r.TokenFunc != nil { + return r.TokenFunc() + } + return r.Token +} + +// do sends a JSON request and decodes the response into out when the status +// matches wantStatus; any other status is returned as an error. +func (r *GitLabReporter) do(ctx context.Context, method, endpoint string, payload interface{}, wantStatus int, out interface{}) (*http.Response, error) { + var reqBody io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshalling request: %w", err) + } + reqBody = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reqBody) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if token := r.resolveToken(); token != "" { + req.Header.Set("PRIVATE-TOKEN", token) + } + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := r.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != wantStatus { + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return resp, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, string(errBody)) + } + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return resp, fmt.Errorf("decoding response: %w", err) + } + } + return resp, nil +} diff --git a/internal/reporting/gitlab_test.go b/internal/reporting/gitlab_test.go new file mode 100644 index 000000000..83bda3c38 --- /dev/null +++ b/internal/reporting/gitlab_test.go @@ -0,0 +1,142 @@ +package reporting + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +type gitlabCall struct { + method string + path string + body map[string]string + token string +} + +func newGitLabReporterServer(t *testing.T, notes []gitlabNote) (*httptest.Server, *[]gitlabCall) { + t.Helper() + var calls []gitlabCall + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := gitlabCall{method: r.Method, path: r.URL.EscapedPath(), token: r.Header.Get("PRIVATE-TOKEN")} + json.NewDecoder(r.Body).Decode(&call.body) + calls = append(calls, call) + + switch { + case r.URL.Path == "/api/v4/user": + json.NewEncoder(w).Encode(gitlabUser{Username: "kelos-bot"}) + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(gitlabNote{ID: 555}) + case r.Method == http.MethodPut: + json.NewEncoder(w).Encode(gitlabNote{ID: 555}) + default: + json.NewEncoder(w).Encode(notes) + } + })) + return server, &calls +} + +func TestGitLabReporterCreateComment(t *testing.T) { + server, calls := newGitLabReporterServer(t, nil) + defer server.Close() + + r := &GitLabReporter{BaseURL: server.URL, Project: "group/sub/repo", Token: "glpat"} + id, err := r.CreateComment(context.Background(), CommentTarget{Kind: "issue", Number: 42}, "hello") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 555 { + t.Errorf("expected note id 555, got %d", id) + } + call := (*calls)[0] + if call.method != http.MethodPost || call.path != "/api/v4/projects/group%2Fsub%2Frepo/issues/42/notes" { + t.Errorf("unexpected request %s %s", call.method, call.path) + } + if call.body["body"] != "hello" || call.token != "glpat" { + t.Errorf("unexpected body/token: %+v", call) + } +} + +func TestGitLabReporterMergeRequestEndpoint(t *testing.T) { + server, calls := newGitLabReporterServer(t, nil) + defer server.Close() + + r := &GitLabReporter{BaseURL: server.URL, Project: "group/repo", Token: "glpat"} + if err := r.UpdateComment(context.Background(), CommentTarget{Kind: SourceKindMergeRequest, Number: 7}, 555, "updated"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + call := (*calls)[0] + if call.method != http.MethodPut || call.path != "/api/v4/projects/group%2Frepo/merge_requests/7/notes/555" { + t.Errorf("unexpected request %s %s", call.method, call.path) + } + if call.body["body"] != "updated" { + t.Errorf("unexpected body %+v", call.body) + } +} + +func TestGitLabReporterFindCommentByMarker(t *testing.T) { + marker := "" + server, calls := newGitLabReporterServer(t, []gitlabNote{ + {ID: 1, Body: "unrelated", Author: gitlabUser{Username: "kelos-bot"}}, + {ID: 2, Body: "status " + marker, Author: gitlabUser{Username: "someone-else"}}, + {ID: 3, Body: "status " + marker, Author: gitlabUser{Username: "kelos-bot"}}, + {ID: 4, Body: "status " + marker, Author: gitlabUser{Username: "Kelos-Bot"}}, + }) + defer server.Close() + + r := &GitLabReporter{BaseURL: server.URL, Project: "group/repo", Token: "glpat"} + id, err := r.FindCommentByMarker(context.Background(), CommentTarget{Kind: "issue", Number: 9}, marker) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 4 { + t.Errorf("expected newest own note 4, got %d", id) + } + if (*calls)[0].path != "/api/v4/user" { + t.Errorf("expected authenticated user lookup first, got %s", (*calls)[0].path) + } + if !strings.HasSuffix((*calls)[1].path, "/issues/9/notes") { + t.Errorf("expected notes listing, got %s", (*calls)[1].path) + } +} + +func TestGitLabReporterAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message":"403 Forbidden"}`)) + })) + defer server.Close() + + r := &GitLabReporter{BaseURL: server.URL, Project: "group/repo"} + if _, err := r.CreateComment(context.Background(), CommentTarget{Kind: "issue", Number: 1}, "x"); err == nil || !strings.Contains(err.Error(), "status 403") { + t.Errorf("expected 403 error, got %v", err) + } +} + +func TestTaskReporterUsesGitLabReporter(t *testing.T) { + server, calls := newGitLabReporterServer(t, nil) + defer server.Close() + + task := newTaskWithAnnotations("mr-task", "default", kelos.TaskPhasePending, map[string]string{ + AnnotationGitHubReporting: "enabled", + AnnotationSourceNumber: "7", + AnnotationSourceKind: SourceKindMergeRequest, + }) + tr := &TaskReporter{ + Client: fake.NewClientBuilder().WithScheme(newTestScheme()).WithObjects(task).Build(), + Reporter: &GitLabReporter{BaseURL: server.URL, Project: "group/repo", Token: "glpat"}, + } + if err := tr.ReportTaskStatus(context.Background(), task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*calls) != 1 || (*calls)[0].path != "/api/v4/projects/group%2Frepo/merge_requests/7/notes" { + t.Errorf("expected one note posted on the merge request, got %+v", *calls) + } +} diff --git a/internal/reporting/watcher.go b/internal/reporting/watcher.go index 2e7fa21ce..02d3f7fb2 100644 --- a/internal/reporting/watcher.go +++ b/internal/reporting/watcher.go @@ -40,6 +40,19 @@ const ( // from. Pairs with AnnotationSourceOwner. AnnotationSourceRepo = "kelos.dev/source-repo" + // AnnotationSourceProvider records the tracker that owns the source item + // when it is not GitHub. Webhook reporting reads it to pick the reporter. + AnnotationSourceProvider = "kelos.dev/source-provider" + + // AnnotationSourceBaseURL records the instance URL of the tracker the event + // came from (e.g. "https://gitlab.example.com"), so reporting can target + // self-hosted instances without server-side configuration. For GitLab, + // AnnotationSourceRepo holds the full project path. + AnnotationSourceBaseURL = "kelos.dev/source-base-url" + + // SourceProviderGitLab is the AnnotationSourceProvider value for GitLab. + SourceProviderGitLab = "gitlab" + // AnnotationWebhookGateway records the name of the WebhookGateway (in the // Task's namespace) that created the Task. The reporting reconciler uses it // to resolve per-gateway GitHub credentials and API base URL, so reporting @@ -100,10 +113,29 @@ const ( LabelSlackReporting = "kelos.dev/slack-reporting" ) -// TaskReporter watches Tasks and reports status changes to GitHub. +// CommentTarget identifies the issue, pull request, or merge request a +// status comment belongs to. Kind carries the AnnotationSourceKind value; +// GitLab needs it to pick the notes endpoint, GitHub ignores it. +type CommentTarget struct { + Kind string + Number int +} + +// CommentReporter creates and updates task status comments on an external +// tracker such as GitHub or GitLab. +type CommentReporter interface { + // FindCommentByMarker returns the newest comment authored by the reporter's + // identity that contains marker, or zero when none exists. + FindCommentByMarker(ctx context.Context, target CommentTarget, marker string) (int64, error) + CreateComment(ctx context.Context, target CommentTarget, body string) (int64, error) + UpdateComment(ctx context.Context, target CommentTarget, commentID int64, body string) error +} + +// TaskReporter watches Tasks and reports status changes as tracker comments +// and, for GitHub, Check Runs. type TaskReporter struct { Client client.Client - Reporter *GitHubReporter + Reporter CommentReporter ChecksReporter *ChecksReporter // Cache backstops AnnotationGitHubCommentID and AnnotationGitHubReportPhase // when the persisted Update has not yet propagated to the controller-runtime @@ -217,6 +249,7 @@ func (tr *TaskReporter) reportViaComment(ctx context.Context, task *kelos.Task) if err != nil { return fmt.Errorf("parsing source number %q: %w", numberStr, err) } + target := CommentTarget{Kind: annotations[AnnotationSourceKind], Number: number} var desiredPhase string switch task.Status.Phase { @@ -286,24 +319,24 @@ func (tr *TaskReporter) reportViaComment(ctx context.Context, task *kelos.Task) } body += "\n\n" + marker if commentID == 0 { - commentID, err = tr.Reporter.FindCommentByMarker(ctx, number, marker) + commentID, err = tr.Reporter.FindCommentByMarker(ctx, target, marker) if err != nil { - return fmt.Errorf("finding sticky GitHub comment for task %s: %w", task.Name, err) + return fmt.Errorf("finding sticky status comment for task %s: %w", task.Name, err) } } } if commentID == 0 { - log.Info("Creating GitHub status comment", "task", task.Name, "number", number, "phase", desiredPhase) - newID, err := tr.Reporter.CreateComment(ctx, number, body) + log.Info("Creating status comment", "task", task.Name, "number", number, "phase", desiredPhase) + newID, err := tr.Reporter.CreateComment(ctx, target, body) if err != nil { - return fmt.Errorf("creating GitHub comment for task %s: %w", task.Name, err) + return fmt.Errorf("creating status comment for task %s: %w", task.Name, err) } commentID = newID } else { - log.Info("Updating GitHub status comment", "task", task.Name, "number", number, "phase", desiredPhase, "commentID", commentID) - if err := tr.Reporter.UpdateComment(ctx, commentID, body); err != nil { - return fmt.Errorf("updating GitHub comment %d for task %s: %w", commentID, task.Name, err) + log.Info("Updating status comment", "task", task.Name, "number", number, "phase", desiredPhase, "commentID", commentID) + if err := tr.Reporter.UpdateComment(ctx, target, commentID, body); err != nil { + return fmt.Errorf("updating status comment %d for task %s: %w", commentID, task.Name, err) } } @@ -318,7 +351,7 @@ func (tr *TaskReporter) reportViaComment(ctx context.Context, task *kelos.Task) func stickyCommentMarker(task *kelos.Task) (string, error) { spawnerName := task.Labels["kelos.dev/taskspawner"] if spawnerName == "" { - return "", fmt.Errorf("sticky GitHub comment for task %s requires kelos.dev/taskspawner label", task.Name) + return "", fmt.Errorf("sticky status comment for task %s requires kelos.dev/taskspawner label", task.Name) } return fmt.Sprintf("", task.Namespace, spawnerName), nil } diff --git a/internal/source/gitlab.go b/internal/source/gitlab.go new file mode 100644 index 000000000..b8bfc57fd --- /dev/null +++ b/internal/source/gitlab.go @@ -0,0 +1,454 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + defaultGitLabBaseURL = "https://gitlab.com" + + gitlabResourceIssues = "issues" + gitlabResourceMergeRequests = "merge_requests" + + pipelineStatusAny = "any" +) + +// GitLabSource discovers issues and merge requests from a GitLab project. +type GitLabSource struct { + // BaseURL is the GitLab instance URL (e.g. "https://gitlab.example.com"). + BaseURL string + // Project is the full project path (e.g. "group/subgroup/project"). + Project string + Types []string + Labels []string + ExcludeLabels []string + State string + // ReviewState gates merge requests by approval outcome + // (approved, changes_requested, any). + ReviewState string + // PipelineStatus gates merge requests by head pipeline status. + PipelineStatus string + Token string + Client *http.Client + TriggerComment string + ExcludeComments []string + AllowedUsers []string +} + +// gitlabItem is the shared subset of the GitLab issue and merge request +// representations. SourceBranch, SHA, and HeadPipeline are only populated for +// merge requests; HeadPipeline only on the single merge request endpoint. +type gitlabItem struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + WebURL string `json:"web_url"` + Labels []string `json:"labels"` + Author gitlabUser `json:"author"` + SourceBranch string `json:"source_branch"` + SHA string `json:"sha"` + HeadPipeline *gitlabPipeline `json:"head_pipeline"` +} + +type gitlabPipeline struct { + Status string `json:"status"` + WebURL string `json:"web_url"` + FinishedAt string `json:"finished_at"` + UpdatedAt string `json:"updated_at"` +} + +type gitlabUser struct { + Username string `json:"username"` +} + +type gitlabNote struct { + Body string `json:"body"` + CreatedAt string `json:"created_at"` + Author gitlabUser `json:"author"` + // System notes are GitLab-generated activity entries (label changes, + // assignments, ...) rather than user comments. + System bool `json:"system"` + // Type is "DiffNote" for inline review comments on a merge request diff. + Type string `json:"type"` + Position *gitlabNotePosition `json:"position"` +} + +type gitlabNotePosition struct { + NewPath string `json:"new_path"` + OldPath string `json:"old_path"` + NewLine int `json:"new_line"` + OldLine int `json:"old_line"` +} + +type gitlabApprovals struct { + Approved bool `json:"approved"` +} + +type gitlabReviewer struct { + State string `json:"state"` +} + +func (s *GitLabSource) baseURL() string { + if s.BaseURL != "" { + return strings.TrimRight(s.BaseURL, "/") + } + return defaultGitLabBaseURL +} + +func (s *GitLabSource) httpClient() *http.Client { + if s.Client != nil { + return s.Client + } + return http.DefaultClient +} + +// Discover fetches issues and/or merge requests from GitLab and returns them +// as WorkItems. Merge requests get an "mr-" ID prefix because GitLab numbers +// issues and merge requests independently, so bare IIDs would collide when +// both types are discovered by one spawner. +func (s *GitLabSource) Discover(ctx context.Context) ([]WorkItem, error) { + policy := gitlabCommentPolicy{ + TriggerComment: s.TriggerComment, + ExcludeComments: s.ExcludeComments, + AllowedUsers: s.AllowedUsers, + } + authorizer := newGitLabCommentAuthorizer(policy) + + excluded := make(map[string]struct{}, len(s.ExcludeLabels)) + for _, l := range s.ExcludeLabels { + excluded[l] = struct{}{} + } + + var items []WorkItem + for _, resource := range s.resolvedResources() { + list, err := s.fetchAllItems(ctx, resource) + if err != nil { + return nil, err + } + + for _, it := range list { + if hasAnyLabel(it.Labels, excluded) { + continue + } + + item := WorkItem{ + ID: strconv.Itoa(it.IID), + Number: it.IID, + Title: it.Title, + Body: it.Description, + URL: it.WebURL, + Labels: it.Labels, + Kind: "Issue", + Branch: it.SourceBranch, + HeadSHA: it.SHA, + } + + var pipelineTriggerTime time.Time + if resource == gitlabResourceMergeRequests { + item.ID = "mr-" + item.ID + item.Kind = "MR" + + keep, triggerTime, err := s.enrichMergeRequest(ctx, it.IID, &item) + if err != nil { + return nil, err + } + if !keep { + continue + } + pipelineTriggerTime = triggerTime + } + + notes, err := s.fetchNotes(ctx, resource, it.IID) + if err != nil { + return nil, fmt.Errorf("fetching notes for %s !%d: %w", resource, it.IID, err) + } + item.Comments = concatBodies(gitlabNoteBodies(gitlabConversationNotes(notes))) + if resource == gitlabResourceMergeRequests { + item.ReviewComments = concatGitLabDiffNotes(notes) + } + + if policy.enabled() { + allowed, triggerTime := evaluateGitLabCommentPolicy(it.Description, it.Author.Username, notes, policy, authorizer) + if !allowed { + continue + } + if s.TriggerComment != "" { + item.TriggerTime = triggerTime + } + } + if pipelineTriggerTime.After(item.TriggerTime) { + item.TriggerTime = pipelineTriggerTime + } + + items = append(items, item) + } + } + + return items, nil +} + +// enrichMergeRequest loads the head pipeline and, when a review-state gate is +// configured, the approval state of a merge request. It reports whether the +// merge request passes the pipeline and review gates and, for a pipeline +// gate, the time the head pipeline finished so a newer run retriggers +// completed Tasks. +func (s *GitLabSource) enrichMergeRequest(ctx context.Context, iid int, item *WorkItem) (bool, time.Time, error) { + // The list endpoint omits head_pipeline, so each merge request costs one + // detail call. + var detail gitlabItem + if err := s.getJSON(ctx, fmt.Sprintf("%s/%s/%d", s.projectURL(), gitlabResourceMergeRequests, iid), nil, &detail); err != nil { + return false, time.Time{}, fmt.Errorf("fetching merge request !%d: %w", iid, err) + } + + var pipelineTriggerTime time.Time + if detail.HeadPipeline != nil { + item.PipelineStatus = detail.HeadPipeline.Status + item.PipelineURL = detail.HeadPipeline.WebURL + pipelineTriggerTime = detail.HeadPipeline.finishedTime() + } + if desired := s.resolvedPipelineStatus(); desired != pipelineStatusAny { + if item.PipelineStatus != desired { + return false, time.Time{}, nil + } + } else { + pipelineTriggerTime = time.Time{} + } + + if desired := s.resolvedReviewState(); desired != reviewStateAny { + reviewState, err := s.fetchReviewState(ctx, iid) + if err != nil { + return false, time.Time{}, err + } + item.ReviewState = reviewState + if reviewState != desired { + return false, time.Time{}, nil + } + } + + return true, pipelineTriggerTime, nil +} + +func (p *gitlabPipeline) finishedTime() time.Time { + for _, raw := range []string{p.FinishedAt, p.UpdatedAt} { + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t + } + } + return time.Time{} +} + +// fetchReviewState aggregates GitLab approvals and reviewer states into the +// GitHub-style review states: "changes_requested" when any reviewer requested +// changes, "approved" when the merge request has the required approvals, and +// "" otherwise. +func (s *GitLabSource) fetchReviewState(ctx context.Context, iid int) (string, error) { + mrURL := fmt.Sprintf("%s/%s/%d", s.projectURL(), gitlabResourceMergeRequests, iid) + + var reviewers []gitlabReviewer + if err := s.getJSON(ctx, mrURL+"/reviewers", nil, &reviewers); err != nil { + return "", fmt.Errorf("fetching reviewers for merge request !%d: %w", iid, err) + } + for _, r := range reviewers { + if r.State == "requested_changes" { + return reviewStateChangesRequested, nil + } + } + + var approvals gitlabApprovals + if err := s.getJSON(ctx, mrURL+"/approvals", nil, &approvals); err != nil { + return "", fmt.Errorf("fetching approvals for merge request !%d: %w", iid, err) + } + if approvals.Approved { + return reviewStateApproved, nil + } + return "", nil +} + +func (s *GitLabSource) resolvedReviewState() string { + if s.ReviewState == "" { + return reviewStateAny + } + return strings.ToLower(s.ReviewState) +} + +func (s *GitLabSource) resolvedPipelineStatus() string { + if s.PipelineStatus == "" { + return pipelineStatusAny + } + return strings.ToLower(s.PipelineStatus) +} + +// resolvedResources maps the API-facing type names to GitLab REST resources. +func (s *GitLabSource) resolvedResources() []string { + if len(s.Types) == 0 { + return []string{gitlabResourceIssues} + } + var resources []string + for _, t := range s.Types { + switch t { + case "issues": + resources = append(resources, gitlabResourceIssues) + case "mergeRequests": + resources = append(resources, gitlabResourceMergeRequests) + } + } + return resources +} + +func hasAnyLabel(labels []string, set map[string]struct{}) bool { + for _, l := range labels { + if _, ok := set[l]; ok { + return true + } + } + return false +} + +// concatGitLabDiffNotes formats inline review comments as "path:line" headers +// followed by the note body, matching the GitHub review comment format. +func concatGitLabDiffNotes(notes []gitlabNote) string { + var parts []string + for _, n := range notes { + if n.System || n.Type != "DiffNote" { + continue + } + body := strings.TrimSpace(n.Body) + if body == "" { + continue + } + if n.Position != nil { + location := n.Position.NewPath + line := n.Position.NewLine + if location == "" { + location = n.Position.OldPath + line = n.Position.OldLine + } + if line > 0 { + location = fmt.Sprintf("%s:%d", location, line) + } + if location != "" { + body = location + "\n" + body + } + } + parts = append(parts, body) + } + return concatBodies(parts) +} + +func (s *GitLabSource) projectURL() string { + return s.baseURL() + "/api/v4/projects/" + url.PathEscape(s.Project) +} + +func (s *GitLabSource) fetchAllItems(ctx context.Context, resource string) ([]gitlabItem, error) { + params := url.Values{} + params.Set("per_page", "100") + state := s.State + if state == "" { + state = "opened" + } + params.Set("state", state) + if len(s.Labels) > 0 { + params.Set("labels", strings.Join(s.Labels, ",")) + } + + var all []gitlabItem + err := s.fetchPages(ctx, s.projectURL()+"/"+resource, params, func(body io.Reader) (int, error) { + var page []gitlabItem + if err := json.NewDecoder(body).Decode(&page); err != nil { + return 0, err + } + all = append(all, page...) + return len(page), nil + }) + return all, err +} + +func (s *GitLabSource) fetchNotes(ctx context.Context, resource string, iid int) ([]gitlabNote, error) { + params := url.Values{} + params.Set("per_page", "100") + params.Set("sort", "asc") + params.Set("order_by", "created_at") + + var all []gitlabNote + err := s.fetchPages(ctx, fmt.Sprintf("%s/%s/%d/notes", s.projectURL(), resource, iid), params, func(body io.Reader) (int, error) { + var page []gitlabNote + if err := json.NewDecoder(body).Decode(&page); err != nil { + return 0, err + } + all = append(all, page...) + return len(page), nil + }) + return all, err +} + +// fetchPages walks GitLab's page-number pagination, following the X-Next-Page +// response header until it is empty or maxPages is reached. +func (s *GitLabSource) fetchPages(ctx context.Context, endpoint string, params url.Values, decode func(io.Reader) (int, error)) error { + page := "1" + for i := 0; page != "" && i < maxPages; i++ { + params.Set("page", page) + resp, err := s.get(ctx, endpoint, params) + if err != nil { + return err + } + n, err := decode(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("decoding response: %w", err) + } + if n == 0 { + break + } + page = resp.Header.Get("X-Next-Page") + } + return nil +} + +// getJSON fetches a single JSON document. +func (s *GitLabSource) getJSON(ctx context.Context, endpoint string, params url.Values, out interface{}) error { + resp, err := s.get(ctx, endpoint, params) + if err != nil { + return err + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + return nil +} + +// get performs an authenticated GET and returns the response for a 200 +// status; any other status is returned as an error with the response body. +func (s *GitLabSource) get(ctx context.Context, endpoint string, params url.Values) (*http.Response, error) { + target := endpoint + if len(params) > 0 { + target += "?" + params.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if s.Token != "" { + req.Header.Set("PRIVATE-TOKEN", s.Token) + } + req.Header.Set("Accept", "application/json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", endpoint, err) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + resp.Body.Close() + return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, string(body)) + } + return resp, nil +} diff --git a/internal/source/gitlab_comment_policy.go b/internal/source/gitlab_comment_policy.go new file mode 100644 index 000000000..15b8911ea --- /dev/null +++ b/internal/source/gitlab_comment_policy.go @@ -0,0 +1,172 @@ +package source + +import ( + "strings" + "time" +) + +// gitlabCommentPolicy gates discovery on slash commands found in the item +// description or its notes. Trigger and exclude commands are matched at the +// start of a line; when both are configured the most recent authorized command +// wins. +type gitlabCommentPolicy struct { + TriggerComment string + ExcludeComments []string + AllowedUsers []string +} + +func (p gitlabCommentPolicy) enabled() bool { + return p.TriggerComment != "" || len(p.ExcludeComments) > 0 +} + +// gitlabCommentAuthorizer decides whether a GitLab user may issue commands. +// Authorization is a username allow-list; an empty list authorizes everyone. +// Access-level checks against /projects/:id/members/all would slot in here. +type gitlabCommentAuthorizer struct { + allowedUsers map[string]struct{} +} + +func newGitLabCommentAuthorizer(policy gitlabCommentPolicy) *gitlabCommentAuthorizer { + a := &gitlabCommentAuthorizer{allowedUsers: make(map[string]struct{}, len(policy.AllowedUsers))} + for _, user := range policy.AllowedUsers { + if normalized := normalizeGitLabUsername(user); normalized != "" { + a.allowedUsers[normalized] = struct{}{} + } + } + return a +} + +func (a *gitlabCommentAuthorizer) isAuthorized(username string) bool { + if len(a.allowedUsers) == 0 { + return true + } + _, ok := a.allowedUsers[normalizeGitLabUsername(username)] + return ok +} + +type gitlabCommentMatch struct { + found bool + hasTime bool + time time.Time + index int +} + +// evaluateGitLabCommentPolicy reports whether the item passes the policy and, +// when a trigger note matched, the note's creation time so re-triggers on a +// finished item can be detected. A trigger in the description counts but +// carries no time. System notes never match. +func evaluateGitLabCommentPolicy(description, author string, notes []gitlabNote, policy gitlabCommentPolicy, authorizer *gitlabCommentAuthorizer) (bool, time.Time) { + if !policy.enabled() { + return true, time.Time{} + } + + authorAuthorized := authorizer.isAuthorized(author) + bodyMatchesTrigger := authorAuthorized && policy.TriggerComment != "" && containsCommand(description, policy.TriggerComment) + bodyMatchesExclude := authorAuthorized && len(policy.ExcludeComments) > 0 && containsAnyCommand(description, policy.ExcludeComments) + + var triggerMatch, excludeMatch gitlabCommentMatch + if policy.TriggerComment != "" { + triggerMatch = latestAuthorizedGitLabNote(notes, []string{policy.TriggerComment}, authorizer) + } + if len(policy.ExcludeComments) > 0 { + excludeMatch = latestAuthorizedGitLabNote(notes, policy.ExcludeComments, authorizer) + } + + switch { + case len(policy.ExcludeComments) == 0: + if triggerMatch.found { + return true, triggerMatch.time + } + return bodyMatchesTrigger, time.Time{} + case policy.TriggerComment == "": + return !excludeMatch.found && !bodyMatchesExclude, time.Time{} + } + + switch compareGitLabCommentMatches(triggerMatch, excludeMatch) { + case 1: + return true, triggerMatch.time + case -1: + return false, time.Time{} + } + if bodyMatchesExclude { + return false, time.Time{} + } + return bodyMatchesTrigger, time.Time{} +} + +func latestAuthorizedGitLabNote(notes []gitlabNote, commands []string, authorizer *gitlabCommentAuthorizer) gitlabCommentMatch { + match := gitlabCommentMatch{index: -1} + for i, note := range notes { + if note.System || !containsAnyCommand(note.Body, commands) || !authorizer.isAuthorized(note.Author.Username) { + continue + } + match.found = true + createdAt, err := time.Parse(time.RFC3339, note.CreatedAt) + if err != nil { + if !match.hasTime { + match.index = i + } + continue + } + if !match.hasTime || createdAt.After(match.time) || (createdAt.Equal(match.time) && i > match.index) { + match.hasTime = true + match.time = createdAt + match.index = i + } + } + return match +} + +// compareGitLabCommentMatches orders two matches by recency: creation time +// when both carry one, otherwise position in the notes list. +func compareGitLabCommentMatches(left, right gitlabCommentMatch) int { + switch { + case left.found && right.found: + if left.hasTime && right.hasTime { + switch { + case left.time.After(right.time): + return 1 + case right.time.After(left.time): + return -1 + } + } + switch { + case left.index > right.index: + return 1 + case right.index > left.index: + return -1 + } + return 0 + case left.found: + return 1 + case right.found: + return -1 + } + return 0 +} + +func normalizeGitLabUsername(username string) string { + return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) +} + +// gitlabConversationNotes drops system notes and inline diff notes so +// {{.Comments}} carries only the discussion thread; diff notes are exposed +// separately through {{.ReviewComments}}. +func gitlabConversationNotes(notes []gitlabNote) []gitlabNote { + conversation := make([]gitlabNote, 0, len(notes)) + for _, n := range notes { + if n.System || n.Type == "DiffNote" { + continue + } + conversation = append(conversation, n) + } + return conversation +} + +func gitlabNoteBodies(notes []gitlabNote) []string { + bodies := make([]string, 0, len(notes)) + for _, n := range notes { + bodies = append(bodies, n.Body) + } + return bodies +} diff --git a/internal/source/gitlab_comment_policy_test.go b/internal/source/gitlab_comment_policy_test.go new file mode 100644 index 000000000..de3a08282 --- /dev/null +++ b/internal/source/gitlab_comment_policy_test.go @@ -0,0 +1,176 @@ +package source + +import ( + "testing" + "time" +) + +func gitlabTestNote(body, user, createdAt string) gitlabNote { + return gitlabNote{Body: body, Author: gitlabUser{Username: user}, CreatedAt: createdAt} +} + +func TestEvaluateGitLabCommentPolicy(t *testing.T) { + trigger := "/kelos fix" + exclude := []string{"/kelos needs-input", "/kelos stop"} + t1 := "2026-01-01T00:00:00Z" + t2 := "2026-01-02T00:00:00Z" + t3 := "2026-01-03T00:00:00Z" + + tests := []struct { + name string + policy gitlabCommentPolicy + description string + author string + notes []gitlabNote + wantAllowed bool + wantTime string + }{ + { + name: "no policy allows everything", + policy: gitlabCommentPolicy{}, + wantAllowed: true, + }, + { + name: "trigger only without any command", + policy: gitlabCommentPolicy{TriggerComment: trigger}, + notes: []gitlabNote{gitlabTestNote("please look", "bob", t1)}, + wantAllowed: false, + }, + { + name: "trigger in note carries its time", + policy: gitlabCommentPolicy{TriggerComment: trigger}, + notes: []gitlabNote{gitlabTestNote("please look", "bob", t1), gitlabTestNote(trigger, "alice", t2)}, + wantAllowed: true, + wantTime: t2, + }, + { + name: "trigger in description carries no time", + policy: gitlabCommentPolicy{TriggerComment: trigger}, + description: "Broken build\n" + trigger, + author: "alice", + wantAllowed: true, + }, + { + name: "trigger must start a line", + policy: gitlabCommentPolicy{TriggerComment: trigger}, + notes: []gitlabNote{gitlabTestNote("maybe run "+trigger+" later", "alice", t1)}, + wantAllowed: false, + }, + { + name: "system notes never match", + policy: gitlabCommentPolicy{TriggerComment: trigger}, + notes: []gitlabNote{{Body: trigger, System: true, Author: gitlabUser{Username: "alice"}, CreatedAt: t1}}, + wantAllowed: false, + }, + { + name: "unauthorized note author is ignored", + policy: gitlabCommentPolicy{TriggerComment: trigger, AllowedUsers: []string{"alice"}}, + notes: []gitlabNote{gitlabTestNote(trigger, "mallory", t1)}, + wantAllowed: false, + }, + { + name: "unauthorized description author is ignored", + policy: gitlabCommentPolicy{TriggerComment: trigger, AllowedUsers: []string{"alice"}}, + description: trigger, + author: "mallory", + wantAllowed: false, + }, + { + name: "allowed users match case-insensitively and without @", + policy: gitlabCommentPolicy{TriggerComment: trigger, AllowedUsers: []string{"@Alice"}}, + notes: []gitlabNote{gitlabTestNote(trigger, "alice", t1)}, + wantAllowed: true, + wantTime: t1, + }, + { + name: "exclude only blocks on any exclude command", + policy: gitlabCommentPolicy{ExcludeComments: exclude}, + notes: []gitlabNote{gitlabTestNote("/kelos stop", "bob", t1)}, + wantAllowed: false, + }, + { + name: "exclude only allows without commands", + policy: gitlabCommentPolicy{ExcludeComments: exclude}, + notes: []gitlabNote{gitlabTestNote("looks fine", "bob", t1)}, + wantAllowed: true, + }, + { + name: "exclude in description blocks", + policy: gitlabCommentPolicy{ExcludeComments: exclude}, + description: "/kelos needs-input", + author: "bob", + wantAllowed: false, + }, + { + name: "latest command wins: exclude after trigger", + policy: gitlabCommentPolicy{TriggerComment: trigger, ExcludeComments: exclude}, + notes: []gitlabNote{gitlabTestNote(trigger, "alice", t1), gitlabTestNote("/kelos needs-input", "alice", t2)}, + wantAllowed: false, + }, + { + name: "latest command wins: trigger after exclude", + policy: gitlabCommentPolicy{TriggerComment: trigger, ExcludeComments: exclude}, + notes: []gitlabNote{gitlabTestNote("/kelos needs-input", "alice", t1), gitlabTestNote(trigger, "alice", t3)}, + wantAllowed: true, + wantTime: t3, + }, + { + name: "ties on time fall back to note order", + policy: gitlabCommentPolicy{TriggerComment: trigger, ExcludeComments: exclude}, + notes: []gitlabNote{gitlabTestNote(trigger, "alice", t1), gitlabTestNote("/kelos stop", "alice", t1)}, + wantAllowed: false, + }, + { + name: "unparseable time falls back to note order", + policy: gitlabCommentPolicy{TriggerComment: trigger, ExcludeComments: exclude}, + notes: []gitlabNote{gitlabTestNote("/kelos stop", "alice", "bad"), gitlabTestNote(trigger, "alice", "bad")}, + wantAllowed: true, + }, + { + name: "note commands outrank description commands", + policy: gitlabCommentPolicy{TriggerComment: trigger, ExcludeComments: exclude}, + description: "/kelos stop", + author: "alice", + notes: []gitlabNote{gitlabTestNote(trigger, "alice", t1)}, + wantAllowed: true, + wantTime: t1, + }, + { + name: "description exclude wins over description trigger", + policy: gitlabCommentPolicy{TriggerComment: trigger, ExcludeComments: exclude}, + description: trigger + "\n/kelos stop", + author: "alice", + wantAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authorizer := newGitLabCommentAuthorizer(tt.policy) + allowed, triggerTime := evaluateGitLabCommentPolicy(tt.description, tt.author, tt.notes, tt.policy, authorizer) + if allowed != tt.wantAllowed { + t.Fatalf("allowed = %v, want %v", allowed, tt.wantAllowed) + } + var want time.Time + if tt.wantTime != "" { + want, _ = time.Parse(time.RFC3339, tt.wantTime) + } + if !triggerTime.Equal(want) { + t.Fatalf("triggerTime = %v, want %v", triggerTime, want) + } + }) + } +} + +func TestGitLabConversationNotes(t *testing.T) { + notes := []gitlabNote{ + {Body: "discussion", Author: gitlabUser{Username: "bob"}}, + {Body: "added label", System: true}, + {Body: "inline", Type: "DiffNote", Position: &gitlabNotePosition{NewPath: "a.go", NewLine: 1}}, + {Body: "reply", Author: gitlabUser{Username: "alice"}}, + } + got := gitlabNoteBodies(gitlabConversationNotes(notes)) + if len(got) != 2 || got[0] != "discussion" || got[1] != "reply" { + t.Fatalf("expected discussion notes only, got %q", got) + } +} diff --git a/internal/source/gitlab_test.go b/internal/source/gitlab_test.go new file mode 100644 index 000000000..a601598f8 --- /dev/null +++ b/internal/source/gitlab_test.go @@ -0,0 +1,379 @@ +package source + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// gitlabFixture holds the data served by newGitLabTestServer for project +// "group/sub/repo". Merge request details (including head_pipeline) come from +// mrs; notes, reviewers, and approvals are keyed by "/". +type gitlabFixture struct { + issues []gitlabItem + mrs []gitlabItem + notes map[string][]gitlabNote + reviewers map[int][]gitlabReviewer + approvals map[int]gitlabApprovals +} + +func newGitLabTestServer(t *testing.T, fx gitlabFixture, seen *[]*http.Request) *httptest.Server { + t.Helper() + const prefix = "/api/v4/projects/group%2Fsub%2Frepo/" + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if seen != nil { + *seen = append(*seen, r) + } + path := r.URL.EscapedPath() + if !strings.HasPrefix(path, prefix) { + t.Errorf("unexpected path %s (project path must be URL-encoded)", path) + w.WriteHeader(http.StatusNotFound) + return + } + rest := strings.TrimPrefix(path, prefix) + parts := strings.Split(rest, "/") + switch { + case rest == "issues": + json.NewEncoder(w).Encode(fx.issues) + case rest == "merge_requests": + json.NewEncoder(w).Encode(fx.mrs) + case len(parts) == 2 && parts[0] == "merge_requests": + iid, _ := strconv.Atoi(parts[1]) + for _, mr := range fx.mrs { + if mr.IID == iid { + json.NewEncoder(w).Encode(mr) + return + } + } + w.WriteHeader(http.StatusNotFound) + case len(parts) == 3 && parts[2] == "notes": + json.NewEncoder(w).Encode(fx.notes[parts[0]+"/"+parts[1]]) + case len(parts) == 3 && parts[2] == "reviewers": + iid, _ := strconv.Atoi(parts[1]) + json.NewEncoder(w).Encode(fx.reviewers[iid]) + case len(parts) == 3 && parts[2] == "approvals": + iid, _ := strconv.Atoi(parts[1]) + json.NewEncoder(w).Encode(fx.approvals[iid]) + default: + t.Errorf("unexpected path %s", path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestGitLabDiscoverIssues(t *testing.T) { + fx := gitlabFixture{ + issues: []gitlabItem{ + {IID: 1, Title: "Bug 1", Description: "Body 1", WebURL: "https://gitlab.example.com/group/sub/repo/-/issues/1", Labels: []string{"bug"}}, + {IID: 2, Title: "Bug 2", Description: "Body 2", WebURL: "https://gitlab.example.com/group/sub/repo/-/issues/2", Labels: []string{"bug", "wontfix"}}, + }, + notes: map[string][]gitlabNote{ + "issues/1": { + {Body: "changed label", System: true, Author: gitlabUser{Username: "alice"}}, + {Body: "first comment", Author: gitlabUser{Username: "alice"}}, + {Body: "second comment", Author: gitlabUser{Username: "bob"}}, + }, + }, + } + var seen []*http.Request + server := newGitLabTestServer(t, fx, &seen) + defer server.Close() + + s := &GitLabSource{ + BaseURL: server.URL, + Project: "group/sub/repo", + Labels: []string{"bug"}, + ExcludeLabels: []string{"wontfix"}, + Token: "glpat-secret", + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item after exclude-label filtering, got %d: %+v", len(items), items) + } + got := items[0] + if got.ID != "1" || got.Number != 1 || got.Kind != "Issue" || got.Title != "Bug 1" || got.Body != "Body 1" { + t.Errorf("unexpected item: %+v", got) + } + if got.URL != fx.issues[0].WebURL { + t.Errorf("unexpected URL %q", got.URL) + } + if got.Comments != "first comment\n---\nsecond comment" { + t.Errorf("expected system notes dropped from comments, got %q", got.Comments) + } + + list := seen[0] + if list.Header.Get("PRIVATE-TOKEN") != "glpat-secret" { + t.Errorf("expected PRIVATE-TOKEN header, got %q", list.Header.Get("PRIVATE-TOKEN")) + } + q := list.URL.Query() + if q.Get("state") != "opened" || q.Get("labels") != "bug" || q.Get("per_page") != "100" { + t.Errorf("unexpected list query: %v", q) + } + if len(seen) != 2 || !strings.HasSuffix(seen[1].URL.EscapedPath(), "/issues/1/notes") { + t.Errorf("expected exactly list + notes requests for issues, got %d requests", len(seen)) + } +} + +func TestGitLabDiscoverMergeRequests(t *testing.T) { + fx := gitlabFixture{ + mrs: []gitlabItem{{ + IID: 7, Title: "Add feature", Description: "MR body", + WebURL: "https://gitlab.example.com/group/sub/repo/-/merge_requests/7", + SourceBranch: "feature-x", SHA: "abc123", + HeadPipeline: &gitlabPipeline{Status: "failed", WebURL: "https://gitlab.example.com/group/sub/repo/-/pipelines/99"}, + }}, + notes: map[string][]gitlabNote{ + "merge_requests/7": { + {Body: "looks good overall", Author: gitlabUser{Username: "bob"}}, + {Body: "rename this", Type: "DiffNote", Author: gitlabUser{Username: "bob"}, Position: &gitlabNotePosition{NewPath: "main.go", NewLine: 12}}, + {Body: "deleted line comment", Type: "DiffNote", Author: gitlabUser{Username: "bob"}, Position: &gitlabNotePosition{OldPath: "old.go", OldLine: 3}}, + }, + }, + } + var seen []*http.Request + server := newGitLabTestServer(t, fx, &seen) + defer server.Close() + + s := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"mergeRequests"}} + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + got := items[0] + if got.ID != "mr-7" || got.Number != 7 || got.Kind != "MR" { + t.Errorf("unexpected merge request identity: %+v", got) + } + if got.Branch != "feature-x" || got.HeadSHA != "abc123" { + t.Errorf("expected branch/sha from merge request, got %+v", got) + } + if got.PipelineStatus != "failed" || got.PipelineURL != fx.mrs[0].HeadPipeline.WebURL { + t.Errorf("expected head pipeline from detail endpoint, got %+v", got) + } + if got.Comments != "looks good overall" { + t.Errorf("expected diff notes excluded from comments, got %q", got.Comments) + } + if got.ReviewComments != "main.go:12\nrename this\n---\nold.go:3\ndeleted line comment" { + t.Errorf("unexpected review comments %q", got.ReviewComments) + } + if got.ReviewState != "" || !got.TriggerTime.IsZero() { + t.Errorf("expected no review state or trigger time without gates, got %+v", got) + } + for _, r := range seen { + if strings.HasSuffix(r.URL.Path, "/approvals") || strings.HasSuffix(r.URL.Path, "/reviewers") { + t.Errorf("review endpoints must not be fetched without a reviewState gate: %s", r.URL.Path) + } + } +} + +func TestGitLabDiscoverPipelineStatusGate(t *testing.T) { + finished := "2026-03-01T10:00:00Z" + fx := gitlabFixture{ + mrs: []gitlabItem{ + {IID: 1, Title: "green", HeadPipeline: &gitlabPipeline{Status: "success"}}, + {IID: 2, Title: "red", HeadPipeline: &gitlabPipeline{Status: "failed", FinishedAt: finished}}, + {IID: 3, Title: "no pipeline"}, + }, + } + server := newGitLabTestServer(t, fx, nil) + defer server.Close() + + s := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"mergeRequests"}, PipelineStatus: "failed"} + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 || items[0].ID != "mr-2" { + t.Fatalf("expected only the failed pipeline MR, got %+v", items) + } + want, _ := time.Parse(time.RFC3339, finished) + if !items[0].TriggerTime.Equal(want) { + t.Errorf("expected pipeline finish time as trigger time, got %v", items[0].TriggerTime) + } +} + +func TestGitLabDiscoverReviewStateGate(t *testing.T) { + fx := gitlabFixture{ + mrs: []gitlabItem{ + {IID: 1, Title: "approved"}, + {IID: 2, Title: "changes requested"}, + {IID: 3, Title: "unreviewed"}, + }, + reviewers: map[int][]gitlabReviewer{ + 2: {{State: "reviewed"}, {State: "requested_changes"}}, + }, + approvals: map[int]gitlabApprovals{ + 1: {Approved: true}, + 2: {Approved: true}, + }, + } + server := newGitLabTestServer(t, fx, nil) + defer server.Close() + + approved := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"mergeRequests"}, ReviewState: "approved"} + items, err := approved.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 || items[0].ID != "mr-1" || items[0].ReviewState != "approved" { + t.Errorf("expected only approved MR (changes requested wins over approval), got %+v", items) + } + + changes := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"mergeRequests"}, ReviewState: "changes_requested"} + items, err = changes.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 || items[0].ID != "mr-2" || items[0].ReviewState != "changes_requested" { + t.Errorf("expected only changes-requested MR, got %+v", items) + } +} + +func TestGitLabDiscoverBothTypesKeepDistinctIDs(t *testing.T) { + fx := gitlabFixture{ + issues: []gitlabItem{{IID: 3, Title: "Issue 3"}}, + mrs: []gitlabItem{{IID: 3, Title: "MR 3", SourceBranch: "b"}}, + } + server := newGitLabTestServer(t, fx, nil) + defer server.Close() + + s := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"issues", "mergeRequests"}} + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 2 || items[0].ID != "3" || items[1].ID != "mr-3" { + t.Errorf("expected ids [3 mr-3], got %+v", items) + } +} + +func TestGitLabDiscoverTriggerComment(t *testing.T) { + fx := gitlabFixture{ + issues: []gitlabItem{ + {IID: 1, Title: "triggered by note", Author: gitlabUser{Username: "author"}}, + {IID: 2, Title: "no trigger", Author: gitlabUser{Username: "author"}}, + {IID: 3, Title: "triggered by unauthorized user", Author: gitlabUser{Username: "author"}}, + {IID: 4, Title: "triggered in description", Description: "/kelos fix", Author: gitlabUser{Username: "alice"}}, + }, + notes: map[string][]gitlabNote{ + "issues/1": { + {Body: "please look", Author: gitlabUser{Username: "bob"}, CreatedAt: "2026-01-01T00:00:00Z"}, + {Body: "/kelos fix", Author: gitlabUser{Username: "alice"}, CreatedAt: "2026-01-02T00:00:00Z"}, + }, + "issues/3": { + {Body: "/kelos fix", Author: gitlabUser{Username: "mallory"}, CreatedAt: "2026-01-02T00:00:00Z"}, + }, + }, + } + server := newGitLabTestServer(t, fx, nil) + defer server.Close() + + s := &GitLabSource{ + BaseURL: server.URL, + Project: "group/sub/repo", + TriggerComment: "/kelos fix", + AllowedUsers: []string{"alice"}, + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 triggered items, got %d: %+v", len(items), items) + } + if items[0].ID != "1" || items[1].ID != "4" { + t.Errorf("expected items 1 and 4, got %+v", items) + } + want := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + if !items[0].TriggerTime.Equal(want) { + t.Errorf("expected trigger time %v, got %v", want, items[0].TriggerTime) + } + if !items[1].TriggerTime.IsZero() { + t.Errorf("description-only trigger must not set TriggerTime, got %v", items[1].TriggerTime) + } +} + +func TestGitLabDiscoverPipelineAndCommentTriggerTimes(t *testing.T) { + fx := gitlabFixture{ + mrs: []gitlabItem{{IID: 5, Title: "red", HeadPipeline: &gitlabPipeline{Status: "failed", FinishedAt: "2026-01-03T00:00:00Z"}}}, + notes: map[string][]gitlabNote{ + "merge_requests/5": {{Body: "/kelos fix", Author: gitlabUser{Username: "alice"}, CreatedAt: "2026-01-02T00:00:00Z"}}, + }, + } + server := newGitLabTestServer(t, fx, nil) + defer server.Close() + + s := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"mergeRequests"}, PipelineStatus: "failed", TriggerComment: "/kelos fix"} + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %+v", items) + } + want := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC) + if !items[0].TriggerTime.Equal(want) { + t.Errorf("expected the later pipeline time to win, got %v", items[0].TriggerTime) + } +} + +func TestGitLabDiscoverPagination(t *testing.T) { + var pages []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/notes") { + json.NewEncoder(w).Encode([]gitlabNote{}) + return + } + page := r.URL.Query().Get("page") + pages = append(pages, page) + switch page { + case "1": + w.Header().Set("X-Next-Page", "2") + json.NewEncoder(w).Encode([]gitlabItem{{IID: 1}}) + case "2": + w.Header().Set("X-Next-Page", "") + json.NewEncoder(w).Encode([]gitlabItem{{IID: 2}}) + default: + t.Errorf("unexpected page %q", page) + } + })) + defer server.Close() + + s := &GitLabSource{BaseURL: server.URL, Project: "group/repo"} + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 2 { + t.Errorf("expected 2 items across pages, got %d", len(items)) + } + if len(pages) != 2 || pages[0] != "1" || pages[1] != "2" { + t.Errorf("expected pages [1 2], got %v", pages) + } +} + +func TestGitLabDiscoverAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"401 Unauthorized"}`)) + })) + defer server.Close() + + s := &GitLabSource{BaseURL: server.URL, Project: "group/repo"} + if _, err := s.Discover(context.Background()); err == nil || !strings.Contains(err.Error(), "status 401") { + t.Errorf("expected 401 error, got %v", err) + } +} diff --git a/internal/source/prompt.go b/internal/source/prompt.go index 38cb842dd..e32380aab 100644 --- a/internal/source/prompt.go +++ b/internal/source/prompt.go @@ -47,6 +47,8 @@ func WorkItemToTemplateVars(item WorkItem) map[string]interface{} { "Branch": item.Branch, "ReviewState": item.ReviewState, "ReviewComments": item.ReviewComments, + "PipelineStatus": item.PipelineStatus, + "PipelineURL": item.PipelineURL, "Time": item.Time, "Schedule": item.Schedule, } @@ -58,7 +60,8 @@ func WorkItemToTemplateVars(item WorkItem) map[string]interface{} { // // Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} // GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} -// GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} +// GitHub pull request and GitLab merge request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} +// GitLab merge request sources also expose: {{.PipelineStatus}}, {{.PipelineURL}} // Cron sources: {{.Time}}, {{.Schedule}} func RenderTemplate(tmplStr string, item WorkItem) (string, error) { tmpl, err := template.New("tmpl").Parse(tmplStr) @@ -83,6 +86,8 @@ func RenderTemplate(tmplStr string, item WorkItem) (string, error) { Branch string ReviewState string ReviewComments string + PipelineStatus string + PipelineURL string Time string Schedule string }{ @@ -97,6 +102,8 @@ func RenderTemplate(tmplStr string, item WorkItem) (string, error) { Branch: item.Branch, ReviewState: item.ReviewState, ReviewComments: item.ReviewComments, + PipelineStatus: item.PipelineStatus, + PipelineURL: item.PipelineURL, Time: item.Time, Schedule: item.Schedule, } diff --git a/internal/source/source.go b/internal/source/source.go index ca47b9677..35c0266c7 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -22,9 +22,13 @@ type WorkItem struct { // ReviewComments contains formatted inline review comments for GitHub PR sources. ReviewComments string // HeadSHA is the commit SHA of the pull request head for GitHub PR sources. - HeadSHA string - Time string // Cron trigger time (RFC3339) - Schedule string // Cron schedule expression + HeadSHA string + // PipelineStatus and PipelineURL describe the head pipeline of a GitLab + // merge request. + PipelineStatus string + PipelineURL string + Time string // Cron trigger time (RFC3339) + Schedule string // Cron schedule expression // TriggerTime is the source-provided re-engagement time for this work item. // For GitHub issues it is the most recent matching trigger comment time. diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index f89c6aed4..6e726f7fa 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -276,6 +276,9 @@ func collect(ctx context.Context, c client.Client, clientset kubernetes.Interfac if s.Spec.When.Jira != nil { sourceTypes["jira"] = struct{}{} } + if s.Spec.When.GitLab != nil { + sourceTypes["gitlab"] = struct{}{} + } } for st := range sourceTypes { report.Features.SourceTypes = append(report.Features.SourceTypes, st) @@ -385,12 +388,16 @@ func taskSpawnerSource(when kelos.When) string { return "github_webhook" case when.LinearWebhook != nil: return "linear_webhook" + case when.GitLabWebhook != nil: + return "gitlab_webhook" case when.GenericWebhook != nil: return "generic_webhook" case when.Cron != nil: return "cron" case when.Jira != nil: return "jira" + case when.GitLab != nil: + return "gitlab" case when.Slack != nil: return "slack" default: diff --git a/internal/webhook/gateway_handler.go b/internal/webhook/gateway_handler.go index cf8ea8e03..e715cb8a3 100644 --- a/internal/webhook/gateway_handler.go +++ b/internal/webhook/gateway_handler.go @@ -161,6 +161,15 @@ func (g *GatewayHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + case GitLabSource: + eventType = "gitlab" + deliveryID = gatewayFallbackDeliveryID(namespace, name, gitlabRequestDeliveryID(r, body)) + if err := ValidateGitLabToken(r.Header.Get(GitLabTokenHeader), secret); err != nil { + log.Error(err, "GitLab token validation failed", "deliveryID", deliveryID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + case GenericSource: // No verification scheme is configured for generic gateways yet. Accept // the delivery but log loudly so the lack of authentication is visible @@ -287,6 +296,10 @@ func (g *GatewayHandler) listGatewayScopedSpawners(ctx context.Context, namespac if when.LinearWebhook != nil { ref = when.LinearWebhook.GatewayRef } + case GitLabSource: + if when.GitLabWebhook != nil { + ref = when.GitLabWebhook.GatewayRef + } case GenericSource: if when.GenericWebhook != nil { ref = when.GenericWebhook.GatewayRef @@ -359,21 +372,25 @@ func gatewaySource(spec *kelos.WebhookGatewaySpec) (WebhookSource, error) { return GitHubSource, nil case spec.Linear != nil: return LinearSource, nil + case spec.GitLab != nil: + return GitLabSource, nil case spec.Generic != nil: return GenericSource, nil default: - return "", fmt.Errorf("no source configured: exactly one of github, linear, or generic is required") + return "", fmt.Errorf("no source configured: exactly one of github, linear, gitlab, or generic is required") } } -// gatewaySecretRef returns the inbound HMAC secretRef for the gateway's source, -// or nil for generic gateways. +// gatewaySecretRef returns the inbound secretRef (HMAC secret or GitLab token) +// for the gateway's source, or nil for generic gateways. func gatewaySecretRef(spec *kelos.WebhookGatewaySpec) *kelos.SecretReference { switch { case spec.GitHub != nil: return &spec.GitHub.SecretRef case spec.Linear != nil: return &spec.Linear.SecretRef + case spec.GitLab != nil: + return &spec.GitLab.SecretRef default: return nil } diff --git a/internal/webhook/gateway_handler_test.go b/internal/webhook/gateway_handler_test.go index 0034a8707..fb3b85ad5 100644 --- a/internal/webhook/gateway_handler_test.go +++ b/internal/webhook/gateway_handler_test.go @@ -76,6 +76,36 @@ func linearGateway(name, namespace, secretName string) *kelos.WebhookGateway { } } +func gitlabGateway(name, namespace, secretName string) *kelos.WebhookGateway { + return &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: kelos.WebhookGatewaySpec{ + GitLab: &kelos.GitLabGateway{ + SecretRef: kelos.SecretReference{Name: secretName}, + }, + }, + } +} + +func gitlabSpawner(name, namespace, gatewayRef string) *kelos.TaskSpawner { + glw := &kelos.GitLabWebhook{Events: []string{"merge_request"}} + if gatewayRef != "" { + glw.GatewayRef = &kelos.GatewayReference{Name: gatewayRef} + } + return &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID(name)}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLabWebhook: glw}, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + Credentials: &kelos.Credentials{Type: "api-key"}, + WorkspaceRef: &kelos.WorkspaceReference{Name: "test-workspace"}, + PromptTemplate: "Handle merge request {{.ID}}", + }, + }, + } +} + func hmacSecret(name, namespace, value string) *corev1.Secret { return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, @@ -170,6 +200,52 @@ func TestGatewayServeHTTP_UnknownPath404(t *testing.T) { } } +func TestGatewayServeHTTP_GitLabValidTokenCreatesTask(t *testing.T) { + g := newTestGatewayHandler(t, + gitlabGateway("gl", "default", "gl-secret"), + hmacSecret("gl-secret", "default", testSecret), + gitlabSpawner("gitlab-a", "default", "gl"), + gitlabSpawner("gitlab-unbound", "default", ""), + ) + + req := httptest.NewRequest(http.MethodPost, "/webhook/default/gl", bytes.NewReader([]byte(gitlabMergeRequestPayload))) + req.Header.Set(GitLabTokenHeader, testSecret) + req.Header.Set(GitLabDeliveryHeader, "uuid-gl-1") + rr := httptest.NewRecorder() + g.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d", rr.Code) + } + var taskList kelos.TaskList + if err := g.client.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatal(err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task for the gateway-bound spawner only, got %d", len(taskList.Items)) + } + if got := taskList.Items[0].Labels["kelos.dev/taskspawner"]; got != "gitlab-a" { + t.Errorf("Expected task owned by gitlab-a, got %q", got) + } +} + +func TestGatewayServeHTTP_GitLabInvalidTokenRejected(t *testing.T) { + g := newTestGatewayHandler(t, + gitlabGateway("gl", "default", "gl-secret"), + hmacSecret("gl-secret", "default", testSecret), + gitlabSpawner("gitlab-a", "default", "gl"), + ) + + req := httptest.NewRequest(http.MethodPost, "/webhook/default/gl", bytes.NewReader([]byte(gitlabMergeRequestPayload))) + req.Header.Set(GitLabTokenHeader, "wrong") + rr := httptest.NewRecorder() + g.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", rr.Code) + } +} + func TestGatewayServeHTTP_GitHubValidSignatureCreatesTask(t *testing.T) { g := newTestGatewayHandler(t, githubGateway("gh", "default", "gh-secret"), diff --git a/internal/webhook/gitlab_filter.go b/internal/webhook/gitlab_filter.go new file mode 100644 index 000000000..58fbae6ea --- /dev/null +++ b/internal/webhook/gitlab_filter.go @@ -0,0 +1,383 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +// GitLabEventData represents parsed GitLab webhook data. Fields are populated +// by ParseGitLabWebhook from the nested payload structure, so no JSON tags are +// used. Kind is "Issue" for issue events and notes on issues, "MR" for merge +// request events, notes on merge requests, and pipelines attached to a merge +// request, and "webhook" otherwise. +type GitLabEventData struct { + // Event is the payload object_kind (issue, merge_request, note, pipeline, push, tag_push). + Event string + // Action is object_attributes.action for issue and merge_request events. + Action string + // Sender is the username of the user who triggered the event. + Sender string + // Project is the full project path (group/subgroup/project). + Project string + ProjectURL string + // Ref is the git ref for push, tag_push, and pipeline events. + Ref string + // Raw parsed event payload for template access. + Payload map[string]interface{} + // Standard template variables. ID is "" for issues, "mr-" for + // merge requests, "pipeline-" for pipelines, and the checkout SHA for + // pushes, matching the identities used by the polling GitLab source. + ID string + Title string + Number int + Body string + URL string + Kind string + // Branch is the merge request source branch, or the branch of a push or + // pipeline event. + Branch string + State string + Labels []string + Draft bool + // NoteOn is the noteable_type of a note event (Issue, MergeRequest, Commit, Snippet). + NoteOn string + CommentBody string + CommentURL string + // PipelineStatus and PipelineURL are set for pipeline events. + PipelineStatus string + PipelineURL string + // HeadSHA is the merge request head commit, pipeline commit, or push checkout SHA. + HeadSHA string +} + +// ParseGitLabWebhook parses a GitLab webhook payload of any supported object_kind. +func ParseGitLabWebhook(payload []byte) (*GitLabEventData, error) { + var raw map[string]interface{} + if err := json.Unmarshal(payload, &raw); err != nil { + return nil, fmt.Errorf("invalid JSON payload: %w", err) + } + + data := &GitLabEventData{ + Payload: raw, + Event: mapString(raw, "object_kind"), + Kind: "webhook", + } + + user := mapObject(raw, "user") + data.Sender = mapString(user, "username") + if data.Sender == "" { + // Push and tag_push events carry the user at the top level. + data.Sender = mapString(raw, "user_username") + } + + project := mapObject(raw, "project") + data.Project = mapString(project, "path_with_namespace") + data.ProjectURL = mapString(project, "web_url") + + attrs := mapObject(raw, "object_attributes") + data.Action = mapString(attrs, "action") + + switch data.Event { + case "issue": + data.applyIssue(attrs, raw) + case "merge_request": + data.applyMergeRequest(attrs, raw) + case "note": + data.NoteOn = mapString(attrs, "noteable_type") + data.CommentBody = mapString(attrs, "note") + data.CommentURL = mapString(attrs, "url") + switch data.NoteOn { + case "Issue": + data.applyIssue(mapObject(raw, "issue"), raw) + case "MergeRequest": + data.applyMergeRequest(mapObject(raw, "merge_request"), raw) + default: + data.ID = strconv.Itoa(mapInt(attrs, "id")) + data.Title = data.NoteOn + " comment" + data.URL = data.CommentURL + } + // Notes have no labels of their own; the commented item's labels are + // only present on the top level for some GitLab versions. + if len(data.Labels) == 0 { + data.Labels = labelTitles(raw["labels"]) + } + case "pipeline": + data.PipelineStatus = mapString(attrs, "status") + data.Ref = mapString(attrs, "ref") + data.Branch = data.Ref + data.HeadSHA = mapString(attrs, "sha") + pipelineID := mapInt(attrs, "id") + data.ID = "pipeline-" + strconv.Itoa(pipelineID) + data.PipelineURL = mapString(attrs, "url") + if data.PipelineURL == "" && data.ProjectURL != "" && pipelineID > 0 { + data.PipelineURL = fmt.Sprintf("%s/-/pipelines/%d", data.ProjectURL, pipelineID) + } + data.Title = fmt.Sprintf("Pipeline %s on %s", data.PipelineStatus, data.Ref) + data.URL = data.PipelineURL + if mr := mapObject(raw, "merge_request"); len(mr) > 0 { + data.Kind = "MR" + data.Number = mapInt(mr, "iid") + data.Branch = mapString(mr, "source_branch") + data.Title = mapString(mr, "title") + data.URL = mapString(mr, "url") + } + case "push", "tag_push": + data.Ref = mapString(raw, "ref") + data.Branch = strings.TrimPrefix(strings.TrimPrefix(data.Ref, "refs/heads/"), "refs/tags/") + data.HeadSHA = mapString(raw, "checkout_sha") + if data.HeadSHA == "" { + data.HeadSHA = mapString(raw, "after") + } + data.ID = data.HeadSHA + data.Title = "Push to " + data.Branch + data.URL = data.ProjectURL + } + + return data, nil +} + +// applyIssue fills the issue identity from an issue object, either the +// object_attributes of an issue event or the issue block of a note event. +func (d *GitLabEventData) applyIssue(issue, raw map[string]interface{}) { + d.Kind = "Issue" + d.Number = mapInt(issue, "iid") + d.ID = strconv.Itoa(d.Number) + d.Title = mapString(issue, "title") + d.Body = mapString(issue, "description") + d.URL = mapString(issue, "url") + d.State = mapString(issue, "state") + d.Labels = labelTitles(issue["labels"]) + if len(d.Labels) == 0 { + d.Labels = labelTitles(raw["labels"]) + } +} + +// applyMergeRequest fills the merge request identity from a merge request +// object, either the object_attributes of a merge_request event or the +// merge_request block of a note event. +func (d *GitLabEventData) applyMergeRequest(mr, raw map[string]interface{}) { + d.Kind = "MR" + d.Number = mapInt(mr, "iid") + d.ID = "mr-" + strconv.Itoa(d.Number) + d.Title = mapString(mr, "title") + d.Body = mapString(mr, "description") + d.URL = mapString(mr, "url") + d.State = mapString(mr, "state") + d.Branch = mapString(mr, "source_branch") + d.HeadSHA = mapString(mapObject(mr, "last_commit"), "id") + if draft, ok := mr["draft"].(bool); ok { + d.Draft = draft + } else if wip, ok := mr["work_in_progress"].(bool); ok { + d.Draft = wip + } + d.Labels = labelTitles(mr["labels"]) + if len(d.Labels) == 0 { + d.Labels = labelTitles(raw["labels"]) + } +} + +// labelTitles extracts label names from a GitLab label array, whose entries +// carry the name under "title". +func labelTitles(value interface{}) []string { + list, ok := value.([]interface{}) + if !ok { + return nil + } + var labels []string + for _, entry := range list { + if label, ok := entry.(map[string]interface{}); ok { + if title := mapString(label, "title"); title != "" { + labels = append(labels, title) + } + } + } + return labels +} + +func mapObject(m map[string]interface{}, key string) map[string]interface{} { + if m == nil { + return nil + } + obj, _ := m[key].(map[string]interface{}) + return obj +} + +func mapString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + s, _ := m[key].(string) + return s +} + +func mapInt(m map[string]interface{}, key string) int { + if m == nil { + return 0 + } + f, _ := m[key].(float64) + return int(f) +} + +// MatchesGitLabEvent reports whether a parsed GitLab event matches the +// spawner's gitlabWebhook configuration. +func MatchesGitLabEvent(config *kelos.GitLabWebhook, eventData *GitLabEventData) (bool, error) { + eventAllowed := false + for _, e := range config.Events { + if e == eventData.Event { + eventAllowed = true + break + } + } + if !eventAllowed { + return false, nil + } + + if config.Project != "" && !strings.EqualFold(config.Project, eventData.Project) { + return false, nil + } + if containsFold(config.ExcludeAuthors, eventData.Sender) { + return false, nil + } + + if len(config.Filters) == 0 { + return true, nil + } + + applicable := false + for i := range config.Filters { + filter := &config.Filters[i] + if filter.Event != eventData.Event { + continue + } + applicable = true + matched, err := matchesGitLabFilter(filter, eventData) + if err != nil { + return false, err + } + if matched { + return true, nil + } + } + // An event kind with no filter of its own is accepted as listed in Events. + return !applicable, nil +} + +func matchesGitLabFilter(filter *kelos.GitLabWebhookFilter, eventData *GitLabEventData) (bool, error) { + if filter.Action != "" && !strings.EqualFold(filter.Action, eventData.Action) { + return false, nil + } + if filter.State != "" && !strings.EqualFold(filter.State, eventData.State) { + return false, nil + } + if filter.Status != "" && !strings.EqualFold(filter.Status, eventData.PipelineStatus) { + return false, nil + } + if filter.NoteOn != "" && filter.NoteOn != eventData.NoteOn { + return false, nil + } + if filter.Draft != nil && *filter.Draft != eventData.Draft { + return false, nil + } + if filter.Author != "" && !strings.EqualFold(filter.Author, eventData.Sender) { + return false, nil + } + if containsFold(filter.ExcludeAuthors, eventData.Sender) { + return false, nil + } + + if filter.Branch != "" { + matched, err := filepath.Match(filter.Branch, eventData.Branch) + if err != nil { + return false, fmt.Errorf("invalid branch pattern %q: %w", filter.Branch, err) + } + if !matched { + return false, nil + } + } + + if filter.BodyPattern != "" { + matched, err := matchesPattern(eventData.CommentBody, filter.BodyPattern) + if err != nil || !matched { + return false, err + } + } + if len(filter.ExcludeBodyPatterns) > 0 { + excluded, err := matchesAnyPattern(eventData.CommentBody, filter.ExcludeBodyPatterns) + if err != nil || excluded { + return false, err + } + } + + if len(filter.Labels) > 0 || len(filter.ExcludeLabels) > 0 { + present := make(map[string]bool, len(eventData.Labels)) + for _, l := range eventData.Labels { + present[strings.ToLower(l)] = true + } + for _, required := range filter.Labels { + if !present[strings.ToLower(required)] { + return false, nil + } + } + for _, excluded := range filter.ExcludeLabels { + if present[strings.ToLower(excluded)] { + return false, nil + } + } + } + + return true, nil +} + +// gitlabInstanceURL reduces a project web URL to the instance URL +// (scheme and host), which is the API base for that GitLab. +func gitlabInstanceURL(projectURL string) string { + parsed, err := url.Parse(projectURL) + if err != nil || parsed.Host == "" { + return "" + } + return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String() +} + +func containsFold(list []string, value string) bool { + for _, entry := range list { + if strings.EqualFold(entry, value) { + return true + } + } + return false +} + +// ExtractGitLabWorkItem converts GitLab webhook data to template variables. +// Every key is always present so templates never trip on a missing key. +func ExtractGitLabWorkItem(eventData *GitLabEventData) map[string]interface{} { + return map[string]interface{}{ + "ID": eventData.ID, + "Title": eventData.Title, + "Kind": eventData.Kind, + "Number": eventData.Number, + "Body": eventData.Body, + "URL": eventData.URL, + "Event": eventData.Event, + "Action": eventData.Action, + "Sender": eventData.Sender, + "Ref": eventData.Ref, + "Branch": eventData.Branch, + "State": eventData.State, + "Labels": strings.Join(eventData.Labels, ", "), + "Repository": eventData.Project, + "RepositoryURL": eventData.ProjectURL, + "NoteOn": eventData.NoteOn, + "CommentBody": eventData.CommentBody, + "CommentURL": eventData.CommentURL, + "PipelineStatus": eventData.PipelineStatus, + "PipelineURL": eventData.PipelineURL, + "HeadSHA": eventData.HeadSHA, + "Payload": eventData.Payload, + } +} diff --git a/internal/webhook/gitlab_filter_test.go b/internal/webhook/gitlab_filter_test.go new file mode 100644 index 000000000..dd0a0c2bd --- /dev/null +++ b/internal/webhook/gitlab_filter_test.go @@ -0,0 +1,276 @@ +package webhook + +import ( + "testing" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +const gitlabMergeRequestPayload = `{ + "object_kind": "merge_request", + "user": {"username": "alice"}, + "project": {"path_with_namespace": "group/sub/repo", "web_url": "https://gitlab.example.com/group/sub/repo"}, + "object_attributes": { + "iid": 7, + "title": "Add feature", + "description": "MR body", + "url": "https://gitlab.example.com/group/sub/repo/-/merge_requests/7", + "state": "opened", + "action": "open", + "source_branch": "feature-x", + "target_branch": "main", + "draft": true, + "last_commit": {"id": "abc123"} + }, + "labels": [{"title": "kelos"}, {"title": "backend"}] +}` + +const gitlabIssuePayload = `{ + "object_kind": "issue", + "user": {"username": "bob"}, + "project": {"path_with_namespace": "group/sub/repo", "web_url": "https://gitlab.example.com/group/sub/repo"}, + "object_attributes": { + "iid": 42, + "title": "Crash on start", + "description": "Stack trace attached", + "url": "https://gitlab.example.com/group/sub/repo/-/issues/42", + "state": "opened", + "action": "open", + "labels": [{"title": "bug"}] + } +}` + +const gitlabNotePayload = `{ + "object_kind": "note", + "user": {"username": "carol"}, + "project": {"path_with_namespace": "group/sub/repo", "web_url": "https://gitlab.example.com/group/sub/repo"}, + "object_attributes": { + "id": 900, + "note": "/kelos fix please", + "noteable_type": "MergeRequest", + "url": "https://gitlab.example.com/group/sub/repo/-/merge_requests/7#note_900" + }, + "merge_request": { + "iid": 7, + "title": "Add feature", + "description": "MR body", + "url": "https://gitlab.example.com/group/sub/repo/-/merge_requests/7", + "state": "opened", + "source_branch": "feature-x", + "last_commit": {"id": "abc123"}, + "labels": [{"title": "kelos"}] + } +}` + +const gitlabPipelinePayload = `{ + "object_kind": "pipeline", + "user": {"username": "alice"}, + "project": {"path_with_namespace": "group/sub/repo", "web_url": "https://gitlab.example.com/group/sub/repo"}, + "object_attributes": { + "id": 555, + "ref": "feature-x", + "sha": "abc123", + "status": "failed", + "url": "https://gitlab.example.com/group/sub/repo/-/pipelines/555" + }, + "merge_request": { + "iid": 7, + "title": "Add feature", + "source_branch": "feature-x", + "url": "https://gitlab.example.com/group/sub/repo/-/merge_requests/7" + } +}` + +const gitlabPushPayload = `{ + "object_kind": "push", + "ref": "refs/heads/main", + "checkout_sha": "deadbeef", + "user_username": "dave", + "project": {"path_with_namespace": "group/sub/repo", "web_url": "https://gitlab.example.com/group/sub/repo"} +}` + +func TestParseGitLabWebhook_MergeRequest(t *testing.T) { + data, err := ParseGitLabWebhook([]byte(gitlabMergeRequestPayload)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.Event != "merge_request" || data.Action != "open" || data.Kind != "MR" { + t.Errorf("unexpected event identity: %+v", data) + } + if data.ID != "mr-7" || data.Number != 7 || data.Title != "Add feature" || data.Body != "MR body" { + t.Errorf("unexpected merge request fields: %+v", data) + } + if data.Sender != "alice" || data.Project != "group/sub/repo" || data.ProjectURL != "https://gitlab.example.com/group/sub/repo" { + t.Errorf("unexpected sender/project: %+v", data) + } + if data.Branch != "feature-x" || data.HeadSHA != "abc123" || data.State != "opened" || !data.Draft { + t.Errorf("unexpected branch/sha/state/draft: %+v", data) + } + if len(data.Labels) != 2 || data.Labels[0] != "kelos" { + t.Errorf("expected top-level labels, got %v", data.Labels) + } +} + +func TestParseGitLabWebhook_Issue(t *testing.T) { + data, err := ParseGitLabWebhook([]byte(gitlabIssuePayload)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.Event != "issue" || data.Kind != "Issue" || data.ID != "42" || data.Number != 42 { + t.Errorf("unexpected issue identity: %+v", data) + } + if data.URL != "https://gitlab.example.com/group/sub/repo/-/issues/42" || data.Sender != "bob" { + t.Errorf("unexpected url/sender: %+v", data) + } + if len(data.Labels) != 1 || data.Labels[0] != "bug" { + t.Errorf("expected object_attributes labels, got %v", data.Labels) + } +} + +func TestParseGitLabWebhook_NoteOnMergeRequest(t *testing.T) { + data, err := ParseGitLabWebhook([]byte(gitlabNotePayload)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.Event != "note" || data.NoteOn != "MergeRequest" || data.Kind != "MR" { + t.Errorf("unexpected note identity: %+v", data) + } + if data.ID != "mr-7" || data.Number != 7 || data.Branch != "feature-x" || data.HeadSHA != "abc123" { + t.Errorf("expected the commented merge request identity, got %+v", data) + } + if data.CommentBody != "/kelos fix please" || data.CommentURL == "" { + t.Errorf("unexpected comment fields: %+v", data) + } + if len(data.Labels) != 1 || data.Labels[0] != "kelos" { + t.Errorf("expected merge request labels, got %v", data.Labels) + } +} + +func TestParseGitLabWebhook_Pipeline(t *testing.T) { + data, err := ParseGitLabWebhook([]byte(gitlabPipelinePayload)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.Event != "pipeline" || data.PipelineStatus != "failed" || data.ID != "pipeline-555" { + t.Errorf("unexpected pipeline identity: %+v", data) + } + if data.PipelineURL != "https://gitlab.example.com/group/sub/repo/-/pipelines/555" { + t.Errorf("unexpected pipeline url %q", data.PipelineURL) + } + if data.Kind != "MR" || data.Number != 7 || data.Branch != "feature-x" || data.HeadSHA != "abc123" { + t.Errorf("expected attached merge request identity, got %+v", data) + } +} + +func TestParseGitLabWebhook_Push(t *testing.T) { + data, err := ParseGitLabWebhook([]byte(gitlabPushPayload)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.Event != "push" || data.Branch != "main" || data.Ref != "refs/heads/main" || data.ID != "deadbeef" { + t.Errorf("unexpected push identity: %+v", data) + } + if data.Sender != "dave" || data.Title != "Push to main" || data.Kind != "webhook" { + t.Errorf("unexpected push fields: %+v", data) + } +} + +func TestParseGitLabWebhook_InvalidJSON(t *testing.T) { + if _, err := ParseGitLabWebhook([]byte("{not json")); err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestMatchesGitLabEvent(t *testing.T) { + mr, _ := ParseGitLabWebhook([]byte(gitlabMergeRequestPayload)) + note, _ := ParseGitLabWebhook([]byte(gitlabNotePayload)) + pipeline, _ := ParseGitLabWebhook([]byte(gitlabPipelinePayload)) + push, _ := ParseGitLabWebhook([]byte(gitlabPushPayload)) + draft := true + notDraft := false + + tests := []struct { + name string + config kelos.GitLabWebhook + event *GitLabEventData + want bool + }{ + {"event listed, no filters", kelos.GitLabWebhook{Events: []string{"merge_request"}}, mr, true}, + {"event not listed", kelos.GitLabWebhook{Events: []string{"issue"}}, mr, false}, + {"project mismatch", kelos.GitLabWebhook{Events: []string{"merge_request"}, Project: "other/repo"}, mr, false}, + {"project match is case-insensitive", kelos.GitLabWebhook{Events: []string{"merge_request"}, Project: "Group/Sub/Repo"}, mr, true}, + {"excluded author", kelos.GitLabWebhook{Events: []string{"merge_request"}, ExcludeAuthors: []string{"alice"}}, mr, false}, + {"action filter matches", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Action: "open"}}}, mr, true}, + {"action filter rejects", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Action: "merge"}}}, mr, false}, + {"filter for other event leaves this event unfiltered", kelos.GitLabWebhook{Events: []string{"merge_request", "issue"}, Filters: []kelos.GitLabWebhookFilter{{Event: "issue", Action: "close"}}}, mr, true}, + {"labels all required", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Labels: []string{"kelos", "backend"}}}}, mr, true}, + {"missing required label", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Labels: []string{"frontend"}}}}, mr, false}, + {"excluded label", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", ExcludeLabels: []string{"Backend"}}}}, mr, false}, + {"draft filter", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Draft: ¬Draft}}}, mr, false}, + {"draft filter matches", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Draft: &draft}}}, mr, true}, + {"branch glob", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Branch: "feature-*"}}}, mr, true}, + {"branch mismatch", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Branch: "release-*"}}}, mr, false}, + {"state filter", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", State: "merged"}}}, mr, false}, + {"or across filters", kelos.GitLabWebhook{Events: []string{"merge_request"}, Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Action: "merge"}, {Event: "merge_request", Action: "open"}}}, mr, true}, + {"note body pattern and subject", kelos.GitLabWebhook{Events: []string{"note"}, Filters: []kelos.GitLabWebhookFilter{{Event: "note", NoteOn: "MergeRequest", BodyPattern: `^/kelos fix`}}}, note, true}, + {"note subject mismatch", kelos.GitLabWebhook{Events: []string{"note"}, Filters: []kelos.GitLabWebhookFilter{{Event: "note", NoteOn: "Issue"}}}, note, false}, + {"note exclude body pattern", kelos.GitLabWebhook{Events: []string{"note"}, Filters: []kelos.GitLabWebhookFilter{{Event: "note", ExcludeBodyPatterns: []string{"please"}}}}, note, false}, + {"note labels come from merge request", kelos.GitLabWebhook{Events: []string{"note"}, Filters: []kelos.GitLabWebhookFilter{{Event: "note", Labels: []string{"kelos"}}}}, note, true}, + {"pipeline status", kelos.GitLabWebhook{Events: []string{"pipeline"}, Filters: []kelos.GitLabWebhookFilter{{Event: "pipeline", Status: "failed"}}}, pipeline, true}, + {"pipeline status mismatch", kelos.GitLabWebhook{Events: []string{"pipeline"}, Filters: []kelos.GitLabWebhookFilter{{Event: "pipeline", Status: "success"}}}, pipeline, false}, + {"push branch", kelos.GitLabWebhook{Events: []string{"push"}, Filters: []kelos.GitLabWebhookFilter{{Event: "push", Branch: "main"}}}, push, true}, + {"filter author", kelos.GitLabWebhook{Events: []string{"push"}, Filters: []kelos.GitLabWebhookFilter{{Event: "push", Author: "dave"}}}, push, true}, + {"filter exclude author", kelos.GitLabWebhook{Events: []string{"push"}, Filters: []kelos.GitLabWebhookFilter{{Event: "push", ExcludeAuthors: []string{"dave"}}}}, push, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MatchesGitLabEvent(&tt.config, tt.event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("MatchesGitLabEvent = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGitLabInstanceURL(t *testing.T) { + tests := map[string]string{ + "https://gitlab.example.com/group/sub/repo": "https://gitlab.example.com", + "http://gitlab-webservice.gitlab.svc:8181/grp/repo": "http://gitlab-webservice.gitlab.svc:8181", + "": "", + "not a url": "", + } + for in, want := range tests { + if got := gitlabInstanceURL(in); got != want { + t.Errorf("gitlabInstanceURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMatchesGitLabEvent_InvalidPattern(t *testing.T) { + note, _ := ParseGitLabWebhook([]byte(gitlabNotePayload)) + config := kelos.GitLabWebhook{Events: []string{"note"}, Filters: []kelos.GitLabWebhookFilter{{Event: "note", BodyPattern: "("}}} + if _, err := MatchesGitLabEvent(&config, note); err == nil { + t.Error("expected error for invalid regular expression") + } +} + +func TestExtractGitLabWorkItem(t *testing.T) { + pipeline, _ := ParseGitLabWebhook([]byte(gitlabPipelinePayload)) + vars := ExtractGitLabWorkItem(pipeline) + + for _, key := range []string{"ID", "Title", "Kind", "Number", "Body", "URL", "Event", "Action", "Sender", "Ref", "Branch", "State", "Labels", "Repository", "RepositoryURL", "NoteOn", "CommentBody", "CommentURL", "PipelineStatus", "PipelineURL", "HeadSHA", "Payload"} { + if _, ok := vars[key]; !ok { + t.Errorf("expected template variable %q to be present", key) + } + } + if vars["Event"] != "pipeline" || vars["PipelineStatus"] != "failed" || vars["Number"] != 7 || vars["Repository"] != "group/sub/repo" { + t.Errorf("unexpected variables: %v", vars) + } + if _, ok := vars["Payload"].(map[string]interface{}); !ok { + t.Errorf("expected Payload to be the parsed map, got %T", vars["Payload"]) + } +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index c1218f35b..6c7e0afef 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -33,8 +33,16 @@ type WebhookSource string const ( GitHubSource WebhookSource = "github" LinearSource WebhookSource = "linear" + GitLabSource WebhookSource = "gitlab" GenericSource WebhookSource = "generic" + // GitLab webhook headers. Idempotency-Key is stable across retries of one + // delivery; X-Gitlab-Event-UUID identifies the event that triggered it. + GitLabEventHeader = "X-Gitlab-Event" + GitLabTokenHeader = "X-Gitlab-Token" + GitLabIdempotencyHeader = "Idempotency-Key" + GitLabDeliveryHeader = "X-Gitlab-Event-UUID" + // GitHub webhook headers GitHubEventHeader = "X-GitHub-Event" GitHubSignatureHeader = "X-Hub-Signature-256" @@ -45,10 +53,11 @@ const ( LinearDeliveryHeader = "Linear-Delivery" ) -// ParsedWebhook holds parsed webhook data for GitHub, Linear, or generic sources. +// ParsedWebhook holds parsed webhook data for GitHub, Linear, GitLab, or generic sources. type ParsedWebhook struct { GitHub *GitHubEventData Linear *LinearEventData + GitLab *GitLabEventData Generic *GenericEventData // Common fields for logging and task naming ID string @@ -236,6 +245,21 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + case GitLabSource: + // The header carries a display name ("Merge Request Hook"); the + // payload object_kind is the canonical event type and is applied in + // processWebhook. + eventType = "gitlab" + deliveryID = gitlabRequestDeliveryID(r, body) + + log.Info("Processing GitLab webhook", "event", r.Header.Get(GitLabEventHeader), "deliveryID", deliveryID, "payloadSize", len(body)) + + if err := ValidateGitLabToken(r.Header.Get(GitLabTokenHeader), h.secret); err != nil { + log.Error(err, "GitLab token validation failed", "deliveryID", deliveryID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + case GenericSource: sourceName, sourceErr := extractSourceFromPath(r.URL.Path) if sourceErr != nil { @@ -302,6 +326,26 @@ func githubDeliveryID(body []byte) string { return "github-" + hex.EncodeToString(sum[:]) } +// gitlabRequestDeliveryID picks the delivery identifier for a GitLab request: +// the retry-stable Idempotency-Key, then the event UUID, then a body hash for +// GitLab versions that send neither. +func gitlabRequestDeliveryID(r *http.Request, body []byte) string { + if id := r.Header.Get(GitLabIdempotencyHeader); id != "" { + return "gitlab-" + id + } + if id := r.Header.Get(GitLabDeliveryHeader); id != "" { + return "gitlab-" + id + } + return gitlabDeliveryID(body) +} + +// gitlabDeliveryID derives a delivery identifier from the body for GitLab +// versions that send no delivery headers. +func gitlabDeliveryID(body []byte) string { + sum := sha256.Sum256(body) + return "gitlab-" + hex.EncodeToString(sum[:]) +} + // processWebhook processes a validated payload with optional // pre-scoped TaskSpawners and SessionSpawners. A non-nil slice, including an // empty one, prevents a cluster-wide list for that resource type. @@ -348,6 +392,25 @@ func (h *WebhookHandler) processWebhook(ctx context.Context, eventType string, p } } + case GitLabSource: + eventData, err := ParseGitLabWebhook(payload) + if err != nil { + return false, fmt.Errorf("failed to parse %s webhook: %w", h.source, err) + } + parsed.GitLab = eventData + parsed.ID = eventData.ID + parsed.Title = eventData.Title + // Use the payload object_kind (e.g. "merge_request", "note") as the + // event type so Task names identify the event. + if eventData.Event != "" { + eventType = eventData.Event + } else { + log.Info("GitLab webhook payload has no 'object_kind' field, will not match any Events filter") + } + if parsed.ID != "" { + log = log.WithValues("gitlabID", parsed.ID) + } + case GenericSource: eventData, err := ParseGenericWebhook(payload) if err != nil { @@ -507,6 +570,10 @@ func (h *WebhookHandler) getMatchingSpawners(ctx context.Context) ([]*kelos.Task if spawner.Spec.When.LinearWebhook != nil && spawner.Spec.When.LinearWebhook.GatewayRef == nil { matching = append(matching, spawner) } + case GitLabSource: + if spawner.Spec.When.GitLabWebhook != nil && spawner.Spec.When.GitLabWebhook.GatewayRef == nil { + matching = append(matching, spawner) + } case GenericSource: if spawner.Spec.When.GenericWebhook != nil && spawner.Spec.When.GenericWebhook.GatewayRef == nil { matching = append(matching, spawner) @@ -553,6 +620,12 @@ func (h *WebhookHandler) matchesSpawner(ctx context.Context, spawner *kelos.Task } return MatchesLinearEvent(spawner.Spec.When.LinearWebhook, parsed.Linear) + case GitLabSource: + if spawner.Spec.When.GitLabWebhook == nil { + return false, nil + } + return MatchesGitLabEvent(spawner.Spec.When.GitLabWebhook, parsed.GitLab) + case GenericSource: if spawner.Spec.When.GenericWebhook == nil { return false, nil @@ -601,6 +674,9 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *kelos.TaskSpaw case LinearSource: templateVars = ExtractLinearWorkItem(parsed.Linear) + case GitLabSource: + templateVars = ExtractGitLabWorkItem(parsed.GitLab) + case GenericSource: templateVars = ExtractGenericWorkItem(parsed.Generic) @@ -720,6 +796,35 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *kelos.TaskSpaw } } + // Stamp reporting annotations for GitLab webhook sources when notes + // reporting is configured and the event maps to an issue or merge request. + if h.source == GitLabSource && parsed.GitLab != nil && parsed.GitLab.Number > 0 && + spawner.Spec.When.GitLabWebhook != nil && + spawner.Spec.When.GitLabWebhook.Reporting != nil && + spawner.Spec.When.GitLabWebhook.Reporting.Comments != nil { + if task.Annotations == nil { + task.Annotations = make(map[string]string) + } + kind := "issue" + if parsed.GitLab.Kind == "MR" { + kind = reporting.SourceKindMergeRequest + } + task.Annotations[reporting.AnnotationSourceProvider] = reporting.SourceProviderGitLab + task.Annotations[reporting.AnnotationSourceKind] = kind + task.Annotations[reporting.AnnotationSourceNumber] = strconv.Itoa(parsed.GitLab.Number) + task.Annotations[reporting.AnnotationSourceRepo] = parsed.GitLab.Project + task.Annotations[reporting.AnnotationSourceBaseURL] = gitlabInstanceURL(parsed.GitLab.ProjectURL) + if h.gatewayName != "" { + task.Annotations[reporting.AnnotationWebhookGateway] = h.gatewayName + } + task.Annotations[reporting.AnnotationGitHubReporting] = "enabled" + commentMode := kelos.GitHubCommentModePerTask + if mode := spawner.Spec.When.GitLabWebhook.Reporting.Comments.Mode; mode != "" { + commentMode = mode + } + task.Annotations[reporting.AnnotationGitHubCommentMode] = string(commentMode) + } + if err := h.client.Create(ctx, task); err != nil { // A configured nameTemplate makes Task names deterministic, so a second // delivery for the same work item collides with the Task already created diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go index 63bc8c1b8..a7d233700 100644 --- a/internal/webhook/handler_test.go +++ b/internal/webhook/handler_test.go @@ -1372,6 +1372,205 @@ const linearIssuePayload = `{ } }` +// newGitLabTestHandler creates a WebhookHandler for GitLab backed by a fake client. +func newGitLabTestHandler(t *testing.T, objs ...client.Object) *WebhookHandler { + t.Helper() + handler := newLinearTestHandler(t, objs...) + handler.source = GitLabSource + return handler +} + +func TestGitLabServeHTTP_RejectsInvalidToken(t *testing.T) { + handler := newGitLabTestHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(gitlabMergeRequestPayload))) + req.Header.Set(GitLabTokenHeader, "wrong") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected %d, got %d", http.StatusUnauthorized, rr.Code) + } +} + +func TestGitLabServeHTTP_RejectsMissingToken(t *testing.T) { + handler := newGitLabTestHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(gitlabMergeRequestPayload))) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected %d, got %d", http.StatusUnauthorized, rr.Code) + } +} + +func TestGitLabServeHTTP_CreatesTaskForMatchingSpawner(t *testing.T) { + spawner := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "gitlab-spawner", Namespace: "default", UID: "gitlab-uid-123"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{ + GitLabWebhook: &kelos.GitLabWebhook{ + Events: []string{"merge_request"}, + Project: "group/sub/repo", + Filters: []kelos.GitLabWebhookFilter{{Event: "merge_request", Action: "open", Labels: []string{"kelos"}}}, + }, + }, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + Credentials: &kelos.Credentials{Type: "api-key"}, + WorkspaceRef: &kelos.WorkspaceReference{Name: "test-workspace"}, + PromptTemplate: "{{.Kind}} !{{.Number}} {{.Title}} on {{.Branch}}", + }, + }, + } + ignored := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "issues-only", Namespace: "default", UID: "gitlab-uid-456"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{Events: []string{"issue"}}}, + TaskTemplate: spawner.Spec.TaskTemplate, + }, + } + handler := newGitLabTestHandler(t, spawner, ignored) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(gitlabMergeRequestPayload))) + req.Header.Set(GitLabTokenHeader, testSecret) + req.Header.Set(GitLabEventHeader, "Merge Request Hook") + req.Header.Set(GitLabIdempotencyHeader, "idem-1") + req.Header.Set(GitLabDeliveryHeader, "uuid-1") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected %d, got %d", http.StatusOK, rr.Code) + } + + var taskList kelos.TaskList + if err := handler.client.List(context.Background(), &taskList); err != nil { + t.Fatal(err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task, got %d", len(taskList.Items)) + } + task := taskList.Items[0] + if task.Labels["kelos.dev/taskspawner"] != "gitlab-spawner" { + t.Errorf("Expected taskspawner label 'gitlab-spawner', got %q", task.Labels["kelos.dev/taskspawner"]) + } + if task.Spec.Prompt != "MR !7 Add feature on feature-x" { + t.Errorf("Unexpected prompt %q", task.Spec.Prompt) + } + if !strings.Contains(task.Name, "merge-request") { + t.Errorf("Expected task name to carry the object_kind, got %q", task.Name) + } + + // A retry with the same Idempotency-Key is deduplicated even though GitLab + // assigns retries a fresh event UUID. + req = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(gitlabMergeRequestPayload))) + req.Header.Set(GitLabTokenHeader, testSecret) + req.Header.Set(GitLabIdempotencyHeader, "idem-1") + req.Header.Set(GitLabDeliveryHeader, "uuid-2") + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("Expected %d on redelivery, got %d", http.StatusOK, rr.Code) + } + if err := handler.client.List(context.Background(), &taskList); err != nil { + t.Fatal(err) + } + if len(taskList.Items) != 1 { + t.Errorf("Expected redelivery to be deduplicated, got %d tasks", len(taskList.Items)) + } +} + +func TestGitLabServeHTTP_StampsReportingAnnotations(t *testing.T) { + spawner := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "gitlab-reporter", Namespace: "default", UID: "gitlab-uid-789"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{ + GitLabWebhook: &kelos.GitLabWebhook{ + Events: []string{"note"}, + Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{Mode: kelos.GitHubCommentModeSticky}}, + }, + }, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + Credentials: &kelos.Credentials{Type: "api-key"}, + WorkspaceRef: &kelos.WorkspaceReference{Name: "test-workspace"}, + PromptTemplate: "{{.CommentBody}}", + }, + }, + } + handler := newGitLabTestHandler(t, spawner) + handler.gatewayName = "gl-gateway" + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(gitlabNotePayload))) + req.Header.Set(GitLabTokenHeader, testSecret) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("Expected %d, got %d", http.StatusOK, rr.Code) + } + + var taskList kelos.TaskList + if err := handler.client.List(context.Background(), &taskList); err != nil { + t.Fatal(err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task, got %d", len(taskList.Items)) + } + got := taskList.Items[0].Annotations + want := map[string]string{ + reporting.AnnotationSourceProvider: reporting.SourceProviderGitLab, + reporting.AnnotationSourceKind: reporting.SourceKindMergeRequest, + reporting.AnnotationSourceNumber: "7", + reporting.AnnotationSourceRepo: "group/sub/repo", + reporting.AnnotationSourceBaseURL: "https://gitlab.example.com", + reporting.AnnotationWebhookGateway: "gl-gateway", + reporting.AnnotationGitHubReporting: "enabled", + reporting.AnnotationGitHubCommentMode: string(kelos.GitHubCommentModeSticky), + } + for k, v := range want { + if got[k] != v { + t.Errorf("annotation %s = %q, want %q", k, got[k], v) + } + } +} + +func TestGitLabServeHTTP_NoReportingAnnotationsWithoutReporting(t *testing.T) { + spawner := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Name: "gitlab-plain", Namespace: "default", UID: "gitlab-uid-790"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{Events: []string{"merge_request"}}}, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + Credentials: &kelos.Credentials{Type: "api-key"}, + WorkspaceRef: &kelos.WorkspaceReference{Name: "test-workspace"}, + PromptTemplate: "{{.Title}}", + }, + }, + } + handler := newGitLabTestHandler(t, spawner) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(gitlabMergeRequestPayload))) + req.Header.Set(GitLabTokenHeader, testSecret) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("Expected %d, got %d", http.StatusOK, rr.Code) + } + + var taskList kelos.TaskList + if err := handler.client.List(context.Background(), &taskList); err != nil { + t.Fatal(err) + } + if len(taskList.Items) != 1 { + t.Fatalf("Expected 1 task, got %d", len(taskList.Items)) + } + if _, ok := taskList.Items[0].Annotations[reporting.AnnotationGitHubReporting]; ok { + t.Error("Expected no reporting annotation when gitlabWebhook.reporting is unset") + } +} + func TestLinearServeHTTP_RejectsInvalidSignature(t *testing.T) { handler := newLinearTestHandler(t) diff --git a/internal/webhook/signature.go b/internal/webhook/signature.go index 23dbd1431..9c8796e03 100644 --- a/internal/webhook/signature.go +++ b/internal/webhook/signature.go @@ -34,6 +34,19 @@ func ValidateLinearSignature(payload []byte, signature string, secret []byte) er return validateHMACSignature(payload, signature, secret) } +// ValidateGitLabToken validates a GitLab webhook delivery. GitLab does not sign +// payloads; it sends the configured secret verbatim in the X-Gitlab-Token +// header, so the check is a constant-time equality against the stored secret. +func ValidateGitLabToken(token string, secret []byte) error { + if token == "" { + return fmt.Errorf("missing token") + } + if !hmac.Equal([]byte(token), secret) { + return fmt.Errorf("token verification failed") + } + return nil +} + // validateHMACSignature performs HMAC-SHA256 validation against the expected hex digest. func validateHMACSignature(payload []byte, expectedSig string, secret []byte) error { mac := hmac.New(sha256.New, secret) diff --git a/internal/webhook/signature_test.go b/internal/webhook/signature_test.go index 158057646..c41911921 100644 --- a/internal/webhook/signature_test.go +++ b/internal/webhook/signature_test.go @@ -97,3 +97,25 @@ func TestValidateLinearSignature(t *testing.T) { }) } } + +func TestValidateGitLabToken(t *testing.T) { + secret := []byte("gitlab-secret-token") + tests := []struct { + name string + token string + wantErr bool + }{ + {"matching token", "gitlab-secret-token", false}, + {"wrong token", "other", true}, + {"prefix of secret", "gitlab-secret", true}, + {"missing token", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateGitLabToken(tt.token, secret) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateGitLabToken() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/workerrunner/runner.go b/internal/workerrunner/runner.go index 595a88e4c..fcbf32d68 100644 --- a/internal/workerrunner/runner.go +++ b/internal/workerrunner/runner.go @@ -434,11 +434,14 @@ func taskAgentEnv(base []string, task *kelos.Task) []string { // git call; this covers tools that read GITHUB_TOKEN/GH_TOKEN directly. // Only env vars already present are overridden, matching whichever the // pod set (GH_TOKEN for github.com, GH_ENTERPRISE_TOKEN for GHE). - if token := currentGitHubToken(); token != "" { + if token := currentToken("KELOS_GITHUB_TOKEN_FILE"); token != "" { env = overrideEnvIfPresent(env, "GITHUB_TOKEN", token) env = overrideEnvIfPresent(env, "GH_TOKEN", token) env = overrideEnvIfPresent(env, "GH_ENTERPRISE_TOKEN", token) } + if token := currentToken("KELOS_GITLAB_TOKEN_FILE"); token != "" { + env = overrideEnvIfPresent(env, "GITLAB_TOKEN", token) + } // Agent images that talk back to an external control plane (progress // streaming, steering, cancellation) need to know which Task they are @@ -462,12 +465,13 @@ func taskAgentEnv(base []string, task *kelos.Task) []string { return env } -// currentGitHubToken reads the current GitHub token from the file named by -// KELOS_GITHUB_TOKEN_FILE, returning "" when the env var is unset or the file -// is missing/unreadable/empty. The file is a kubelet-synced secret volume, so -// it reflects controller-side token refreshes within the kubelet sync period. -func currentGitHubToken() string { - tokenFile := os.Getenv("KELOS_GITHUB_TOKEN_FILE") +// currentToken reads the current workspace token from the file named by the +// given env var (KELOS_GITHUB_TOKEN_FILE or KELOS_GITLAB_TOKEN_FILE), returning +// "" when the env var is unset or the file is missing/unreadable/empty. The +// file is a kubelet-synced secret volume, so it reflects controller-side token +// refreshes within the kubelet sync period. +func currentToken(fileEnv string) string { + tokenFile := os.Getenv(fileEnv) if tokenFile == "" { return "" } diff --git a/internal/workerrunner/runner_test.go b/internal/workerrunner/runner_test.go index 7cead94a5..a582bfd94 100644 --- a/internal/workerrunner/runner_test.go +++ b/internal/workerrunner/runner_test.go @@ -252,6 +252,27 @@ func TestTaskAgentEnvRefreshesGitHubTokenFromFile(t *testing.T) { } } +func TestTaskAgentEnvRefreshesGitLabTokenFromFile(t *testing.T) { + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("glpat_fresh_token\n"), 0o600); err != nil { + t.Fatalf("writing token file: %v", err) + } + t.Setenv("KELOS_GITHUB_TOKEN_FILE", "") + t.Setenv("KELOS_GITLAB_TOKEN_FILE", tokenFile) + + task := &kelos.Task{Spec: kelos.TaskSpec{Prompt: "Fix the bug"}} + env := taskAgentEnv([]string{"GITLAB_TOKEN=stale", "OTHER=value"}, task) + + if got := lastEnvValue(env, "GITLAB_TOKEN"); got != "glpat_fresh_token" { + t.Errorf("GITLAB_TOKEN = %q, want refreshed token", got) + } + for _, kv := range env { + if strings.HasPrefix(kv, "GITHUB_TOKEN=") || strings.HasPrefix(kv, "GH_TOKEN=") { + t.Errorf("GitHub token unexpectedly set for a GitLab workspace: %q", kv) + } + } +} + func TestTaskAgentEnvNoTokenFileLeavesEnvUnchanged(t *testing.T) { t.Setenv("KELOS_GITHUB_TOKEN_FILE", "") diff --git a/opencode/Dockerfile b/opencode/Dockerfile index ec50a2c24..cbef561d3 100644 --- a/opencode/Dockerfile +++ b/opencode/Dockerfile @@ -26,6 +26,15 @@ RUN ARCH=$(dpkg --print-architecture) \ && tar -C /usr/local -xzf "/tmp/${TARBALL}" \ && rm "/tmp/${TARBALL}" "/tmp/${TARBALL}.sha256" +ARG GLAB_VERSION=1.116.0 +RUN ARCH=$(dpkg --print-architecture) \ + && DEB="glab_${GLAB_VERSION}_linux_${ARCH}.deb" \ + && curl -fsSL -o "/tmp/${DEB}" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${DEB}" \ + && curl -fsSL -o /tmp/glab-checksums.txt "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/checksums.txt" \ + && (cd /tmp && sha256sum --ignore-missing -c glab-checksums.txt) \ + && dpkg -i "/tmp/${DEB}" \ + && rm "/tmp/${DEB}" /tmp/glab-checksums.txt + ENV PATH="/usr/local/go/bin:${PATH}" ARG OPENCODE_VERSION=1.18.25 @@ -37,6 +46,9 @@ RUN chmod +x /kelos_entrypoint.sh COPY hack/agent-gh-wrapper.sh /usr/local/bin/gh RUN chmod +x /usr/local/bin/gh +COPY hack/agent-glab-wrapper.sh /usr/local/bin/glab +RUN chmod +x /usr/local/bin/glab + ARG TARGETARCH COPY bin/kelos-capture-linux-${TARGETARCH} /kelos/kelos-capture diff --git a/skills/kelos/SKILL.md b/skills/kelos/SKILL.md index ace271c87..0d9d8c3ce 100644 --- a/skills/kelos/SKILL.md +++ b/skills/kelos/SKILL.md @@ -50,7 +50,7 @@ Kelos resources use `apiVersion: kelos.dev/v1alpha2`. | `Session` | Persistent interactive agent conversation | `spec.worker`, `spec.volumeClaimTemplate` | | `Workspace` | Git repository for the agent | `spec.repo`, `spec.ref`, `spec.secretRef`, `spec.remotes`, `spec.files` | | `AgentConfig` | Reusable instructions and tools | `spec.agentsMD`, `spec.plugins`, `spec.skills`, `spec.mcpServers` | -| `TaskSpawner` | Creates Tasks from external sources | `spec.when.githubIssues`, `spec.when.githubPullRequests`, `spec.when.cron`, `spec.when.jira`, per-source `pollInterval`, `spec.taskTemplate`, `spec.maxConcurrency`, `spec.maxTotalTasks`, `spec.suspend` | +| `TaskSpawner` | Creates Tasks from external sources | `spec.when.githubIssues`, `spec.when.githubPullRequests`, `spec.when.cron`, `spec.when.jira`, `spec.when.gitlab`, per-source `pollInterval`, `spec.taskTemplate`, `spec.maxConcurrency`, `spec.maxTotalTasks`, `spec.suspend` | Task phases are `Pending`, `Waiting`, `Running`, `Succeeded`, and `Failed`. Session phases are `Pending`, `Ready`, and `Failed`. diff --git a/skills/kelos/references/taskspawner.yaml b/skills/kelos/references/taskspawner.yaml index a85e28da3..ff0c4eccb 100644 --- a/skills/kelos/references/taskspawner.yaml +++ b/skills/kelos/references/taskspawner.yaml @@ -156,6 +156,39 @@ spec: {{.Body}} maxConcurrency: 2 --- +# TaskSpawner with GitLab source (instance URL and project derive from the Workspace repo; +# the Workspace must set provider: gitlab and its secret's GITLAB_TOKEN key holds the token) +apiVersion: kelos.dev/v1alpha2 +kind: TaskSpawner +metadata: + name: gitlab-worker +spec: + when: + gitlab: + types: ["issues", "mergeRequests"] + labels: ["kelos"] + commentPolicy: + triggerComment: "/kelos fix" + allowedUsers: ["alice"] + reporting: + comments: + mode: Sticky + pollInterval: 2m + taskTemplate: + type: claude-code + workspaceRef: + name: my-gitlab-workspace + credentials: + type: oauth + secretRef: + name: claude-oauth-token + branch: "{{if .Branch}}{{.Branch}}{{else}}kelos-issue-{{.Number}}{{end}}" + promptTemplate: | + Work on GitLab {{.Kind}} #{{.Number}}: {{.Title}} + + {{.Body}} + maxConcurrency: 2 +--- # TaskSpawner distributing generated Tasks across multiple agent accounts apiVersion: kelos.dev/v1alpha2 kind: TaskSpawner diff --git a/skills/kelos/references/troubleshooting.md b/skills/kelos/references/troubleshooting.md index b71c1a440..0da83db80 100644 --- a/skills/kelos/references/troubleshooting.md +++ b/skills/kelos/references/troubleshooting.md @@ -40,7 +40,7 @@ failure state. - Check whether `maxTotalTasks` is reached. - Check whether `spec.suspend: true` is set. - For source polling, check the source-specific `pollInterval` under - `spec.when.githubIssues`, `spec.when.githubPullRequests`, or `spec.when.jira`. + `spec.when.githubIssues`, `spec.when.githubPullRequests`, `spec.when.jira`, or `spec.when.gitlab`. - For comment-controlled sources, check whether the latest authorized command includes or excludes the item. @@ -55,7 +55,9 @@ failure state. ## Agent Cannot Push Or Create PRs - Ensure the Workspace Secret contains a valid `GITHUB_TOKEN` or GitHub App - credentials. + credentials. For `provider: gitlab` Workspaces the Secret must hold a + `GITLAB_TOKEN` key with the `api` scope; a Task or TaskSpawner whose Secret + lacks it is marked `Failed` with the missing key in its status message. - Verify token permissions include repository write access. - For workflow edits, verify the credential includes workflow permissions. - For GitHub Apps, check that `appID`, `installationID`, and `privateKey` are diff --git a/skills/kelos/references/workspace.yaml b/skills/kelos/references/workspace.yaml index a80ff13bd..f868a546d 100644 --- a/skills/kelos/references/workspace.yaml +++ b/skills/kelos/references/workspace.yaml @@ -25,6 +25,19 @@ spec: secretRef: name: github-app-creds --- +# GitLab workspace (gitlab.com, self-hosted, or in-cluster). The secret holds a +# GITLAB_TOKEN key; git authenticates as oauth2 and glab is preconfigured. +apiVersion: kelos.dev/v1alpha2 +kind: Workspace +metadata: + name: my-gitlab-workspace +spec: + repo: https://gitlab.example.com/group/repo.git + ref: main + provider: gitlab + secretRef: + name: gitlab-token +--- # Fork workflow — workspace points to fork, with upstream remote apiVersion: kelos.dev/v1alpha2 kind: Workspace diff --git a/test/integration/taskspawner_test.go b/test/integration/taskspawner_test.go index 44bf1e508..034b79c7e 100644 --- a/test/integration/taskspawner_test.go +++ b/test/integration/taskspawner_test.go @@ -233,6 +233,158 @@ var _ = Describe("TaskSpawner Controller", func() { }) }) + Context("When creating a TaskSpawner with a GitLab workspace", func() { + It("Should create a Deployment with GITLAB_TOKEN env var", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-gitlab", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a Secret with a GitLab token") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitlab-token", + Namespace: ns.Name, + }, + StringData: map[string]string{ + "GITLAB_TOKEN": "glpat-test", + }, + } + Expect(k8sClient.Create(ctx, secret)).Should(Succeed()) + + By("Creating a GitLab Workspace") + ws := &kelos.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-workspace-gitlab", + Namespace: ns.Name, + }, + Spec: kelos.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/repo.git", + Ref: "main", + Provider: kelos.WorkspaceProviderGitLab, + SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, + }, + } + Expect(k8sClient.Create(ctx, ws)).Should(Succeed()) + + By("Creating a TaskSpawner with a gitlab source") + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-gitlab", + Namespace: ns.Name, + }, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{ + GitLab: &kelos.GitLab{Labels: []string{"kelos"}}, + }, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + Credentials: &kelos.Credentials{ + Type: kelos.CredentialTypeOAuth, + SecretRef: &kelos.SecretReference{Name: "claude-credentials"}, + }, + WorkspaceRef: &kelos.WorkspaceReference{Name: "test-workspace-gitlab"}, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + By("Verifying a Deployment is created") + deployLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdDeploy := &appsv1.Deployment{} + Eventually(func() bool { + err := k8sClient.Get(ctx, deployLookupKey, createdDeploy) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Verifying the Deployment has GITLAB_TOKEN env var and GitLab args") + Expect(createdDeploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := createdDeploy.Spec.Template.Spec.Containers[0] + Expect(container.Env).To(HaveLen(1)) + Expect(container.Env[0].Name).To(Equal("GITLAB_TOKEN")) + Expect(container.Env[0].ValueFrom.SecretKeyRef.Name).To(Equal("gitlab-token")) + Expect(container.Env[0].ValueFrom.SecretKeyRef.Key).To(Equal("GITLAB_TOKEN")) + Expect(container.Args).To(ContainElement("--gitlab-base-url=https://gitlab.example.com")) + Expect(container.Args).To(ContainElement("--gitlab-project=group/repo")) + }) + + It("Should mark the TaskSpawner Failed when the workspace provider does not match", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-gitlab-mismatch", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a GitHub-style Secret") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "github-token", + Namespace: ns.Name, + }, + StringData: map[string]string{ + "GITHUB_TOKEN": "glpat-under-the-wrong-key", + }, + } + Expect(k8sClient.Create(ctx, secret)).Should(Succeed()) + + By("Creating a default (github) Workspace pointing at GitLab") + ws := &kelos.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-workspace-github", + Namespace: ns.Name, + }, + Spec: kelos.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/repo.git", + SecretRef: &kelos.SecretReference{Name: "github-token"}, + }, + } + Expect(k8sClient.Create(ctx, ws)).Should(Succeed()) + + By("Creating a TaskSpawner with a gitlab source") + ts := &kelos.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-gitlab-mismatch", + Namespace: ns.Name, + }, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{}}, + TaskTemplate: kelos.TaskTemplate{ + Type: "claude-code", + Credentials: &kelos.Credentials{ + Type: kelos.CredentialTypeOAuth, + SecretRef: &kelos.SecretReference{Name: "claude-credentials"}, + }, + WorkspaceRef: &kelos.WorkspaceReference{Name: "test-workspace-github"}, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + By("Verifying the TaskSpawner is marked Failed with the provider mismatch") + tsLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdTS := &kelos.TaskSpawner{} + Eventually(func() kelos.TaskSpawnerPhase { + if err := k8sClient.Get(ctx, tsLookupKey, createdTS); err != nil { + return "" + } + return createdTS.Status.Phase + }, timeout, interval).Should(Equal(kelos.TaskSpawnerPhaseFailed)) + Expect(createdTS.Status.Message).To(ContainSubstring("requires a Workspace with provider gitlab")) + + By("Verifying no Deployment was created") + deploy := &appsv1.Deployment{} + Consistently(func() bool { + err := k8sClient.Get(ctx, tsLookupKey, deploy) + return apierrors.IsNotFound(err) + }, "2s", interval).Should(BeTrue()) + }) + }) + Context("When deleting a TaskSpawner", func() { It("Should clean up and remove the finalizer", func() { By("Creating a namespace") From 3ce94cfef5c11fe8e90b3bc370c452531b6c1b94 Mon Sep 17 00:00:00 2001 From: Jan Soukup Date: Wed, 2 Sep 2026 18:45:49 +0200 Subject: [PATCH 2/7] fix(codereview): comments cleaned, dockerfiles more secure --- api/v1alpha2/webhookgateway_types.go | 4 ++-- api/v1alpha2/workspace_types.go | 7 ++++--- cursor/Dockerfile | 2 +- gemini/Dockerfile | 2 +- internal/webhook/signature.go | 3 ++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/api/v1alpha2/webhookgateway_types.go b/api/v1alpha2/webhookgateway_types.go index 427e99d05..b1fdbb317 100644 --- a/api/v1alpha2/webhookgateway_types.go +++ b/api/v1alpha2/webhookgateway_types.go @@ -6,8 +6,8 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type WebhookGatewayPhase string const ( - // WebhookGatewayPhaseAuthenticated means inbound deliveries are HMAC-verified - // against the gateway's secret. + // WebhookGatewayPhaseAuthenticated means inbound deliveries are cryptographically + // verified against the gateway's secret (HMAC for GitHub/Linear, token equality for GitLab). WebhookGatewayPhaseAuthenticated WebhookGatewayPhase = "Authenticated" // WebhookGatewayPhaseSecretMissing means a required Secret is not configured // or not yet present. diff --git a/api/v1alpha2/workspace_types.go b/api/v1alpha2/workspace_types.go index 48a0adbac..3baca32c2 100644 --- a/api/v1alpha2/workspace_types.go +++ b/api/v1alpha2/workspace_types.go @@ -59,9 +59,10 @@ type WorkspaceSpec struct { // +optional Provider string `json:"provider,omitempty"` - // SecretRef references a Secret containing the Provider's token key - // (GITHUB_TOKEN for github, GITLAB_TOKEN for gitlab) for git - // authentication and CLI operations. + // SecretRef references a Secret containing credentials for the Provider. + // For github (or when Provider is omitted), it must contain GITHUB_TOKEN + // or GitHub App keys (appID, installationID, privateKey). + // For gitlab, it must contain GITLAB_TOKEN. // +optional SecretRef *SecretReference `json:"secretRef,omitempty"` diff --git a/cursor/Dockerfile b/cursor/Dockerfile index c90b3b79d..a8349ed93 100644 --- a/cursor/Dockerfile +++ b/cursor/Dockerfile @@ -31,7 +31,7 @@ RUN ARCH=$(dpkg --print-architecture) \ && DEB="glab_${GLAB_VERSION}_linux_${ARCH}.deb" \ && curl -fsSL -o "/tmp/${DEB}" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${DEB}" \ && curl -fsSL -o /tmp/glab-checksums.txt "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/checksums.txt" \ - && (cd /tmp && sha256sum --ignore-missing -c glab-checksums.txt) \ + && (cd /tmp && grep "${DEB}" glab-checksums.txt | sha256sum -c -) \ && dpkg -i "/tmp/${DEB}" \ && rm "/tmp/${DEB}" /tmp/glab-checksums.txt diff --git a/gemini/Dockerfile b/gemini/Dockerfile index ac79416a6..2c8278b26 100644 --- a/gemini/Dockerfile +++ b/gemini/Dockerfile @@ -31,7 +31,7 @@ RUN ARCH=$(dpkg --print-architecture) \ && DEB="glab_${GLAB_VERSION}_linux_${ARCH}.deb" \ && curl -fsSL -o "/tmp/${DEB}" "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${DEB}" \ && curl -fsSL -o /tmp/glab-checksums.txt "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/checksums.txt" \ - && (cd /tmp && sha256sum --ignore-missing -c glab-checksums.txt) \ + && (cd /tmp && grep "${DEB}" glab-checksums.txt | sha256sum -c -) \ && dpkg -i "/tmp/${DEB}" \ && rm "/tmp/${DEB}" /tmp/glab-checksums.txt diff --git a/internal/webhook/signature.go b/internal/webhook/signature.go index 9c8796e03..e304c8b67 100644 --- a/internal/webhook/signature.go +++ b/internal/webhook/signature.go @@ -36,7 +36,8 @@ func ValidateLinearSignature(payload []byte, signature string, secret []byte) er // ValidateGitLabToken validates a GitLab webhook delivery. GitLab does not sign // payloads; it sends the configured secret verbatim in the X-Gitlab-Token -// header, so the check is a constant-time equality against the stored secret. +// header, so the check is constant-time equality for equal-length inputs +// (hmac.Equal returns immediately if lengths differ). func ValidateGitLabToken(token string, secret []byte) error { if token == "" { return fmt.Errorf("missing token") From eaa8472fa31b5931ddbbea2f1735f0235c6518b0 Mon Sep 17 00:00:00 2001 From: Jan Soukup Date: Wed, 2 Sep 2026 18:52:35 +0200 Subject: [PATCH 3/7] fix(codereview): add missing logs --- internal/cli/printer.go | 9 +++++++++ internal/webhook/gitlab_filter.go | 1 + 2 files changed, 10 insertions(+) diff --git a/internal/cli/printer.go b/internal/cli/printer.go index d9ee16dcb..6272a5f12 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -321,6 +321,12 @@ func printTaskSpawnerDetail(w io.Writer, ts *kelos.TaskSpawner) { if len(gl.Labels) > 0 { printField(w, "Labels", fmt.Sprintf("%v", gl.Labels)) } + if gl.ReviewState != "" { + printField(w, "Review State", gl.ReviewState) + } + if gl.PipelineStatus != "" { + printField(w, "Pipeline Status", gl.PipelineStatus) + } } else if ts.Spec.When.Cron != nil { printField(w, "Source", "Cron") printField(w, "Schedule", ts.Spec.When.Cron.Schedule) @@ -345,6 +351,9 @@ func printTaskSpawnerDetail(w io.Writer, ts *kelos.TaskSpawner) { if gl.Project != "" { printField(w, "Project", gl.Project) } + if len(gl.ExcludeAuthors) > 0 { + printField(w, "Exclude Authors", fmt.Sprintf("%v", gl.ExcludeAuthors)) + } } else if ts.Spec.When.GenericWebhook != nil { gw := ts.Spec.When.GenericWebhook printField(w, "Source", "Generic Webhook") diff --git a/internal/webhook/gitlab_filter.go b/internal/webhook/gitlab_filter.go index 58fbae6ea..a01322137 100644 --- a/internal/webhook/gitlab_filter.go +++ b/internal/webhook/gitlab_filter.go @@ -123,6 +123,7 @@ func ParseGitLabWebhook(payload []byte) (*GitLabEventData, error) { if mr := mapObject(raw, "merge_request"); len(mr) > 0 { data.Kind = "MR" data.Number = mapInt(mr, "iid") + data.ID = "mr-" + strconv.Itoa(data.Number) data.Branch = mapString(mr, "source_branch") data.Title = mapString(mr, "title") data.URL = mapString(mr, "url") From 3f8149b38e7b9fb39f01f980119c7e29455f271d Mon Sep 17 00:00:00 2001 From: Jan Soukup Date: Wed, 2 Sep 2026 22:45:51 +0200 Subject: [PATCH 4/7] fix(codereview): cleaner structure, fixed edge-cases and polished --- api/v1alpha2/taskspawner_types.go | 23 ++-- api/v1alpha2/webhookgateway_types.go | 2 +- cmd/kelos-spawner/main.go | 6 +- cmd/kelos-spawner/main_test.go | 10 +- cmd/kelos-webhook-server/main.go | 7 + cmd/kelos-webhook-server/reporting.go | 55 +++++--- cmd/kelos-webhook-server/reporting_test.go | 40 ++++-- docs/integration.md | 2 +- docs/reference.md | 6 +- .../taskspawner-ci-remediation.yaml | 5 +- .../taskspawner-webhook.yaml | 8 +- hack/agent-glab-wrapper.sh | 7 +- internal/controller/job_builder_test.go | 2 +- .../controller/session_controller_test.go | 49 +++++++ internal/controller/task_controller.go | 1 + internal/controller/task_controller_test.go | 129 ++++++++++++++++++ internal/controller/taskspawner_controller.go | 2 +- .../controller/taskspawner_controller_test.go | 9 +- .../taskspawner_deployment_builder.go | 21 ++- .../taskspawner_deployment_builder_test.go | 37 +++++ .../controller/webhookgateway_controller.go | 43 +++--- .../webhookgateway_controller_test.go | 69 +++++++++- internal/controller/workspace_provider.go | 7 +- .../controller/workspace_provider_test.go | 17 ++- internal/conversion/taskspawner.go | 14 +- internal/conversion/taskspawner_test.go | 28 +++- internal/conversion/workspace.go | 6 +- internal/conversion/workspace_test.go | 38 +++++- .../kelos-crds/templates/taskspawner-crd.yaml | 13 ++ .../templates/webhookgateway-crd.yaml | 2 +- .../kelos-crds/templates/workspace-crd.yaml | 7 +- .../kelos/templates/webhook-server.yaml | 5 +- internal/manifests/charts/kelos/values.yaml | 4 + internal/manifests/install-crd.yaml | 22 ++- internal/reporting/watcher.go | 7 +- internal/reporting/watcher_test.go | 4 +- internal/source/comment_policy.go | 109 +++++++++++++++ internal/source/github_comment_policy.go | 113 +++------------ internal/source/gitlab.go | 63 +++++---- internal/source/gitlab_comment_policy.go | 111 ++++----------- internal/source/gitlab_comment_policy_test.go | 6 + internal/source/gitlab_test.go | 103 ++++++++++---- internal/source/prompt_test.go | 18 ++- internal/telemetry/telemetry_test.go | 16 ++- internal/webhook/gitlab_filter.go | 11 +- internal/webhook/gitlab_filter_test.go | 24 ++-- internal/webhook/handler.go | 6 +- internal/webhook/handler_test.go | 8 +- test/integration/taskspawner_test.go | 2 +- 49 files changed, 918 insertions(+), 379 deletions(-) create mode 100644 internal/source/comment_policy.go diff --git a/api/v1alpha2/taskspawner_types.go b/api/v1alpha2/taskspawner_types.go index f8571e36e..ff115a974 100644 --- a/api/v1alpha2/taskspawner_types.go +++ b/api/v1alpha2/taskspawner_types.go @@ -104,17 +104,17 @@ type GitHubReporting struct { Checks *GitHubChecksReporting `json:"checks,omitempty"` } -// GitHubCommentMode controls how task status comments are reused. -type GitHubCommentMode string +// CommentMode controls how task status comments are reused. +type CommentMode string const ( - // GitHubCommentModePerTask creates one status comment for each Task and + // CommentModePerTask creates one status comment for each Task and // updates it as that Task's phase changes. - GitHubCommentModePerTask GitHubCommentMode = "PerTask" + CommentModePerTask CommentMode = "PerTask" - // GitHubCommentModeSticky maintains one status comment per TaskSpawner and + // CommentModeSticky maintains one status comment per TaskSpawner and // originating issue or pull request, updating it across Tasks. - GitHubCommentModeSticky GitHubCommentMode = "Sticky" + CommentModeSticky CommentMode = "Sticky" ) // GitHubCommentsReporting configures GitHub task status comment reporting. @@ -125,7 +125,7 @@ type GitHubCommentsReporting struct { // +optional // +kubebuilder:default=PerTask // +kubebuilder:validation:Enum=PerTask;Sticky - Mode GitHubCommentMode `json:"mode,omitempty"` + Mode CommentMode `json:"mode,omitempty"` } // GitHubChecksReporting configures GitHub Check Run reporting for pull @@ -429,7 +429,7 @@ type GitLabCommentsReporting struct { // +optional // +kubebuilder:default=PerTask // +kubebuilder:validation:Enum=PerTask;Sticky - Mode GitHubCommentMode `json:"mode,omitempty"` + Mode CommentMode `json:"mode,omitempty"` } // GitLab discovers issues and merge requests from a GitLab project. @@ -442,6 +442,9 @@ type GitLab struct { // BaseURL overrides the GitLab instance URL used for API calls (for // example "https://gitlab.example.com" or an in-cluster service URL). // When empty, the scheme and host of the workspace repo URL are used. + // Set it for a GitLab served under a relative URL root + // ("https://example.com/gitlab"); the path is then excluded from the + // project path derived from the workspace repo URL. // +kubebuilder:validation:Pattern="^https?://.+" // +optional BaseURL string `json:"baseUrl,omitempty"` @@ -454,7 +457,7 @@ type GitLab struct { // Types specifies which item types to discover: "issues", // "mergeRequests", or both. - // +kubebuilder:validation:Items:Enum=issues;mergeRequests + // +kubebuilder:validation:items:Enum=issues;mergeRequests // +kubebuilder:default={"issues"} // +optional Types []string `json:"types,omitempty"` @@ -708,7 +711,7 @@ type GitLabWebhook struct { // "push", or "tag_push". // +kubebuilder:validation:Required // +kubebuilder:validation:MinItems=1 - // +kubebuilder:validation:Items:Enum=issue;merge_request;note;pipeline;push;tag_push + // +kubebuilder:validation:items:Enum=issue;merge_request;note;pipeline;push;tag_push Events []string `json:"events"` // GatewayRef binds this source to a WebhookGateway in the same namespace whose diff --git a/api/v1alpha2/webhookgateway_types.go b/api/v1alpha2/webhookgateway_types.go index b1fdbb317..ce7276dbc 100644 --- a/api/v1alpha2/webhookgateway_types.go +++ b/api/v1alpha2/webhookgateway_types.go @@ -79,7 +79,7 @@ type GitLabGateway struct { // APIBaseURL is the GitLab instance URL used for status reporting (for // example "https://gitlab.example.com" or an in-cluster service URL). When - // empty, the instance URL is taken from the originating webhook payload. + // empty, "https://gitlab.com" is used. // +kubebuilder:validation:Pattern="^https?://.+" // +optional APIBaseURL string `json:"apiBaseURL,omitempty"` diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index dd62ec200..9b741ad2e 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -683,8 +683,8 @@ func reportingEnabled(ts *kelos.TaskSpawner) bool { // resolvedCommentMode returns the configured comment mode. The deprecated // Enabled field and an empty Comments configuration retain PerTask behavior. -func resolvedCommentMode(ts *kelos.TaskSpawner) kelos.GitHubCommentMode { - var mode kelos.GitHubCommentMode +func resolvedCommentMode(ts *kelos.TaskSpawner) kelos.CommentMode { + var mode kelos.CommentMode switch { case ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Reporting != nil && ts.Spec.When.GitHubIssues.Reporting.Comments != nil: mode = ts.Spec.When.GitHubIssues.Reporting.Comments.Mode @@ -696,7 +696,7 @@ func resolvedCommentMode(ts *kelos.TaskSpawner) kelos.GitHubCommentMode { if mode != "" { return mode } - return kelos.GitHubCommentModePerTask + return kelos.CommentModePerTask } // checksReportingEnabled returns true when GitHub Checks API reporting is diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index 0b579e204..c2c884367 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -2079,7 +2079,7 @@ func TestSourceAnnotations_GitLab(t *testing.T) { When: kelos.When{ GitLab: &kelos.GitLab{ Reporting: &kelos.GitLabReporting{ - Comments: &kelos.GitLabCommentsReporting{Mode: kelos.GitHubCommentModeSticky}, + Comments: &kelos.GitLabCommentsReporting{Mode: kelos.CommentModeSticky}, }, }, }, @@ -2093,7 +2093,7 @@ func TestSourceAnnotations_GitLab(t *testing.T) { if issue[reporting.AnnotationGitHubReporting] != "enabled" { t.Errorf("Expected reporting enabled annotation, got %v", issue) } - if issue[reporting.AnnotationGitHubCommentMode] != string(kelos.GitHubCommentModeSticky) { + if issue[reporting.AnnotationGitHubCommentMode] != string(kelos.CommentModeSticky) { t.Errorf("Expected sticky comment mode, got %q", issue[reporting.AnnotationGitHubCommentMode]) } @@ -2435,7 +2435,7 @@ func TestReportingEnabled_GitLab(t *testing.T) { if !reportingEnabled(enabled) { t.Error("Expected reporting to be enabled for GitLab comments reporting") } - if got := resolvedCommentMode(enabled); got != kelos.GitHubCommentModePerTask { + if got := resolvedCommentMode(enabled); got != kelos.CommentModePerTask { t.Errorf("resolvedCommentMode = %q, want PerTask default", got) } } @@ -2566,7 +2566,7 @@ func TestSourceAnnotations_StickyComments(t *testing.T) { When: kelos.When{ GitHubPullRequests: &kelos.GitHubPullRequests{ Reporting: &kelos.GitHubReporting{ - Comments: &kelos.GitHubCommentsReporting{Mode: kelos.GitHubCommentModeSticky}, + Comments: &kelos.GitHubCommentsReporting{Mode: kelos.CommentModeSticky}, }, }, }, @@ -2577,7 +2577,7 @@ func TestSourceAnnotations_StickyComments(t *testing.T) { if annotations[reporting.AnnotationGitHubReporting] != "enabled" { t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationGitHubReporting]) } - if annotations[reporting.AnnotationGitHubCommentMode] != string(kelos.GitHubCommentModeSticky) { + if annotations[reporting.AnnotationGitHubCommentMode] != string(kelos.CommentModeSticky) { t.Errorf("Expected Sticky comment mode, got %q", annotations[reporting.AnnotationGitHubCommentMode]) } } diff --git a/cmd/kelos-webhook-server/main.go b/cmd/kelos-webhook-server/main.go index af0e092dc..ab5358759 100644 --- a/cmd/kelos-webhook-server/main.go +++ b/cmd/kelos-webhook-server/main.go @@ -50,6 +50,7 @@ func main() { githubAPIBaseURL string githubTokenFile string gitlabToken string + gitlabBaseURL string ) flag.StringVar(&source, "source", "", "Webhook source type (github, linear, gitlab, or generic). Ignored when --gateway-mode is set.") @@ -65,6 +66,7 @@ func main() { flag.StringVar(&githubAPIBaseURL, "github-api-base-url", "", "GitHub API base URL for enterprise servers (env: GITHUB_API_BASE_URL)") flag.StringVar(&githubTokenFile, "github-token-file", "", "Path to file containing GitHub token for reporting.") flag.StringVar(&gitlabToken, "gitlab-token", "", "GitLab access token for status notes in --source=gitlab mode (env: GITLAB_TOKEN)") + flag.StringVar(&gitlabBaseURL, "gitlab-base-url", "", "GitLab instance URL for status notes in --source=gitlab mode, e.g. https://gitlab.example.com (env: GITLAB_BASE_URL; default https://gitlab.com)") opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) flag.Parse() @@ -83,6 +85,9 @@ func main() { if gitlabToken == "" { gitlabToken = os.Getenv("GITLAB_TOKEN") } + if gitlabBaseURL == "" { + gitlabBaseURL = os.Getenv("GITLAB_BASE_URL") + } if githubAppID == "" { githubAppID = os.Getenv("GITHUB_APP_ID") } @@ -248,10 +253,12 @@ func main() { reportingReconciler := &reportingReconciler{ Client: mgr.GetClient(), config: reportingConfig{ + Source: webhookSource, TokenResolver: tokenResolver, GitHubAPIBaseURL: githubAPIBaseURL, GitHubAppID: reportingGitHubAppID, GitLabToken: gitlabToken, + GitLabBaseURL: gitlabBaseURL, GatewayMode: gatewayMode, }, } diff --git a/cmd/kelos-webhook-server/reporting.go b/cmd/kelos-webhook-server/reporting.go index f88a20e4d..7371d3633 100644 --- a/cmd/kelos-webhook-server/reporting.go +++ b/cmd/kelos-webhook-server/reporting.go @@ -16,6 +16,7 @@ import ( kelos "github.com/kelos-dev/kelos/api/v1alpha2" "github.com/kelos-dev/kelos/internal/githubapp" "github.com/kelos-dev/kelos/internal/reporting" + "github.com/kelos-dev/kelos/internal/webhook" ) // reportingConfig holds the configuration for the reporting reconciler. @@ -25,15 +26,23 @@ import ( // resolver covers all supported credential paths (PAT, GitHub App, token // file, env), shared with the webhook handler for consistency. type reportingConfig struct { + // Source is the provider served in per-source mode. Tasks stamped for + // another provider are left to that provider's server. + Source webhook.WebhookSource TokenResolver func(context.Context) (string, error) GitHubAPIBaseURL string GitHubAppID string - // GitLabToken authenticates status notes in --source=gitlab mode. Gateway - // mode resolves the token from the gateway's credentialsRef instead. - GitLabToken string - GatewayMode bool + // GitLabToken and GitLabBaseURL configure status notes in --source=gitlab + // mode. Gateway mode resolves both from the WebhookGateway instead. The + // instance URL is never taken from the webhook payload, which would let a + // forged delivery redirect the token to an attacker-controlled host. + GitLabToken string + GitLabBaseURL string + GatewayMode bool } +const defaultGitLabBaseURL = "https://gitlab.com" + // reportingReconciler watches Tasks with GitHub reporting annotations // and reports their status back to GitHub. type reportingReconciler struct { @@ -69,7 +78,12 @@ func (r *reportingReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, nil } - if task.Annotations[reporting.AnnotationSourceProvider] == reporting.SourceProviderGitLab { + isGitLab := task.Annotations[reporting.AnnotationSourceProvider] == reporting.SourceProviderGitLab + if !r.config.GatewayMode && isGitLab != (r.config.Source == webhook.GitLabSource) { + log.V(1).Info("Skipping reporting: task belongs to another provider's server", "task", task.Name, "source", r.config.Source) + return ctrl.Result{}, nil + } + if isGitLab { return r.reportGitLab(ctx, &task) } @@ -124,26 +138,22 @@ func (r *reportingReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } // reportGitLab posts status notes for a Task created from a GitLab webhook. -// The project path and instance URL come from Task annotations; the token -// comes from the bound gateway's credentialsRef or the server's GitLab token. +// The project path comes from a Task annotation; the token and instance URL +// come from the bound gateway or the server configuration. func (r *reportingReconciler) reportGitLab(ctx context.Context, task *kelos.Task) (ctrl.Result, error) { log := ctrl.Log.WithName("reporting") project := task.Annotations[reporting.AnnotationSourceRepo] - baseURL := task.Annotations[reporting.AnnotationSourceBaseURL] - if project == "" || baseURL == "" { - log.Info("Skipping reporting: missing source project/base-url annotation", "task", task.Name) + if project == "" { + log.Info("Skipping reporting: missing source project annotation", "task", task.Name) return ctrl.Result{}, nil } - token, apiBaseURL, err := r.resolveGitLabReportingCreds(ctx, task) + token, baseURL, err := r.resolveGitLabReportingCreds(ctx, task) if err != nil { log.Error(err, "Resolving GitLab credentials for reporting", "task", task.Name) return ctrl.Result{}, fmt.Errorf("resolving reporting credentials: %w", err) } - if apiBaseURL != "" { - baseURL = apiBaseURL - } reporter := &reporting.TaskReporter{ Client: r.Client, @@ -161,15 +171,17 @@ func (r *reportingReconciler) reportGitLab(ctx context.Context, task *kelos.Task return ctrl.Result{}, nil } -// resolveGitLabReportingCreds returns the GitLab token and, for gateway-owned -// Tasks, the gateway's API base URL override (empty when not configured). +// resolveGitLabReportingCreds returns the GitLab token and instance URL for +// the Task: the bound gateway's credentialsRef and apiBaseURL for +// gateway-owned Tasks, otherwise the server configuration. An unset instance +// URL means gitlab.com. func (r *reportingReconciler) resolveGitLabReportingCreds(ctx context.Context, task *kelos.Task) (string, string, error) { gwName := task.Annotations[reporting.AnnotationWebhookGateway] if gwName == "" { if r.config.GitLabToken == "" { return "", "", fmt.Errorf("no GitLab token configured for reporting") } - return r.config.GitLabToken, "", nil + return r.config.GitLabToken, gitLabBaseURLOrDefault(r.config.GitLabBaseURL), nil } var gw kelos.WebhookGateway @@ -187,7 +199,14 @@ func (r *reportingReconciler) resolveGitLabReportingCreds(ctx context.Context, t if token == "" { return "", "", fmt.Errorf("webhook gateway %s credentials contain no GITLAB_TOKEN", gwName) } - return token, gw.Spec.GitLab.APIBaseURL, nil + return token, gitLabBaseURLOrDefault(gw.Spec.GitLab.APIBaseURL), nil +} + +func gitLabBaseURLOrDefault(baseURL string) string { + if baseURL == "" { + return defaultGitLabBaseURL + } + return baseURL } // resolveReportingCreds returns the GitHub token resolver, API base URL, and diff --git a/cmd/kelos-webhook-server/reporting_test.go b/cmd/kelos-webhook-server/reporting_test.go index 5e90a2872..1a075a580 100644 --- a/cmd/kelos-webhook-server/reporting_test.go +++ b/cmd/kelos-webhook-server/reporting_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "sync/atomic" "testing" "time" @@ -25,6 +26,7 @@ import ( kelos "github.com/kelos-dev/kelos/api/v1alpha2" "github.com/kelos-dev/kelos/internal/reporting" + "github.com/kelos-dev/kelos/internal/webhook" ) func newReportingTestScheme(t *testing.T) *runtime.Scheme { @@ -44,6 +46,8 @@ func TestReportingReconcilerSkipsTasksOwnedByOtherServerMode(t *testing.T) { name string gatewayMode bool gatewayName string + source webhook.WebhookSource + provider string resolver func(context.Context) (string, error) }{ {name: "gateway server skips source-specific task", gatewayMode: true}, @@ -52,6 +56,11 @@ func TestReportingReconcilerSkipsTasksOwnedByOtherServerMode(t *testing.T) { gatewayName: "github", resolver: func(context.Context) (string, error) { return "token", nil }, }, + // Per-source servers of different providers watch the same Tasks; each + // must leave the other provider's Tasks alone instead of failing on + // credentials it does not have. + {name: "github server skips gitlab task", source: webhook.GitHubSource, provider: reporting.SourceProviderGitLab}, + {name: "gitlab server skips github task", source: webhook.GitLabSource}, } for _, tt := range tests { @@ -65,12 +74,13 @@ func TestReportingReconcilerSkipsTasksOwnedByOtherServerMode(t *testing.T) { reporting.AnnotationSourceOwner: "owner", reporting.AnnotationSourceRepo: "repo", reporting.AnnotationWebhookGateway: tt.gatewayName, + reporting.AnnotationSourceProvider: tt.provider, }, }, } reconciler := &reportingReconciler{ Client: fake.NewClientBuilder().WithScheme(newReportingTestScheme(t)).WithObjects(task).Build(), - config: reportingConfig{GatewayMode: tt.gatewayMode, TokenResolver: tt.resolver}, + config: reportingConfig{GatewayMode: tt.gatewayMode, Source: tt.source, TokenResolver: tt.resolver}, } result, err := reconciler.Reconcile(context.Background(), ctrl.Request{ @@ -128,10 +138,13 @@ func TestResolveReportingCredsFromGateway(t *testing.T) { } func TestReportingReconcilerPostsGitLabNote(t *testing.T) { + var mu sync.Mutex var gotPath, gotToken string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() gotPath = r.URL.EscapedPath() gotToken = r.Header.Get("PRIVATE-TOKEN") + mu.Unlock() w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]int64{"id": 77}) })) @@ -146,11 +159,11 @@ func TestReportingReconcilerPostsGitLabNote(t *testing.T) { wantToken string }{ { - name: "per-source server uses the configured GitLab token and the payload instance URL", + name: "per-source server uses the configured GitLab token and instance URL, never the payload's", annotations: map[string]string{ - reporting.AnnotationSourceBaseURL: server.URL, + reporting.AnnotationSourceBaseURL: "https://attacker.example", }, - config: reportingConfig{GitLabToken: "server-token"}, + config: reportingConfig{Source: webhook.GitLabSource, GitLabToken: "server-token", GitLabBaseURL: server.URL}, wantToken: "server-token", }, { @@ -171,7 +184,7 @@ func TestReportingReconcilerPostsGitLabNote(t *testing.T) { }, }, annotations: map[string]string{ - reporting.AnnotationSourceBaseURL: "https://gitlab.external.example", + reporting.AnnotationSourceBaseURL: "https://attacker.example", reporting.AnnotationWebhookGateway: "gl", }, config: reportingConfig{GatewayMode: true}, @@ -181,10 +194,12 @@ func TestReportingReconcilerPostsGitLabNote(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + mu.Lock() gotPath, gotToken = "", "" + mu.Unlock() annotations := map[string]string{ reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationGitHubCommentMode: string(kelos.GitHubCommentModePerTask), + reporting.AnnotationGitHubCommentMode: string(kelos.CommentModePerTask), reporting.AnnotationSourceProvider: reporting.SourceProviderGitLab, reporting.AnnotationSourceKind: reporting.SourceKindMergeRequest, reporting.AnnotationSourceNumber: "7", @@ -209,11 +224,14 @@ func TestReportingReconcilerPostsGitLabNote(t *testing.T) { }); err != nil { t.Fatalf("Reconcile() error = %v", err) } - if gotPath != "/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/notes" { - t.Errorf("note posted to %q", gotPath) + mu.Lock() + path, token := gotPath, gotToken + mu.Unlock() + if path != "/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/notes" { + t.Errorf("note posted to %q", path) } - if gotToken != tt.wantToken { - t.Errorf("PRIVATE-TOKEN = %q, want %q", gotToken, tt.wantToken) + if token != tt.wantToken { + t.Errorf("PRIVATE-TOKEN = %q, want %q", token, tt.wantToken) } var updated kelos.Task @@ -328,7 +346,7 @@ func TestReportingReconcilerUsesGatewayGitHubAppIdentityForStickyComments(t *tes Labels: map[string]string{"kelos.dev/taskspawner": "reviewer"}, Annotations: map[string]string{ reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationGitHubCommentMode: string(kelos.GitHubCommentModeSticky), + reporting.AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), reporting.AnnotationSourceOwner: "owner", reporting.AnnotationSourceRepo: "repo", reporting.AnnotationSourceNumber: "42", diff --git a/docs/integration.md b/docs/integration.md index 7b5fa7bba..a6120319e 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -370,7 +370,7 @@ Then add a project or group webhook in GitLab (Settings → Webhooks) pointing t **GitLab-specific variables:** `{{.Event}}`, `{{.Action}}`, `{{.Sender}}`, `{{.Repository}}`, `{{.Kind}}` (`Issue`, `MR`, or `webhook`), `{{.Number}}`, `{{.Branch}}`, `{{.State}}`, `{{.Labels}}`, `{{.HeadSHA}}`, `{{.NoteOn}}`, `{{.CommentBody}}`, `{{.CommentURL}}`, `{{.PipelineStatus}}`, `{{.PipelineURL}}`, `{{.Payload}}`. Notes and pipelines attached to a merge request carry that merge request's `{{.ID}}` (`mr-`), `{{.Number}}`, and `{{.Branch}}`. -**Status notes:** set `reporting.comments.mode` (`PerTask` or `Sticky`) on the spawner to post Task status notes on the originating issue or merge request. The per-source server reads its token from `webhookServer.sources.gitlab.tokenSecretName` (a Secret with a `GITLAB_TOKEN` key); gateway-bound spawners use the gateway's `spec.gitlab.credentialsRef` and optional `spec.gitlab.apiBaseURL`. +**Status notes:** set `reporting.comments.mode` (`PerTask` or `Sticky`) on the spawner to post Task status notes on the originating issue or merge request. The per-source server reads its token from `webhookServer.sources.gitlab.tokenSecretName` (a Secret with a `GITLAB_TOKEN` key) and posts to `webhookServer.sources.gitlab.baseUrl` (default `https://gitlab.com`); gateway-bound spawners use the gateway's `spec.gitlab.credentialsRef` and `spec.gitlab.apiBaseURL`. The instance URL always comes from configuration, never from the webhook payload. ### Generic Webhooks diff --git a/docs/reference.md b/docs/reference.md index f87ee04df..a3342dc27 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -857,7 +857,7 @@ to receive refreshed credentials during long-running work. | `spec.when.gitlabWebhook.filters[].draft` | Filter `merge_request` events by draft status | No | | `spec.when.gitlabWebhook.filters[].author` | Filter by the username of the user who triggered the event | No | | `spec.when.gitlabWebhook.filters[].excludeAuthors` | Exclude events triggered by any of these usernames | No | -| `spec.when.gitlabWebhook.reporting.comments.mode` | Enables status notes on the originating GitLab issue or merge request (`issue`, `merge_request`, `note`, and merge-request `pipeline` events). `PerTask` (default) creates one note for each Task; `Sticky` maintains one note per TaskSpawner and item across Tasks. Requires a GitLab token: `webhookServer.sources.gitlab.tokenSecretName` on the per-source server, or `spec.gitlab.credentialsRef` on the bound [WebhookGateway](#webhookgateway) | No | +| `spec.when.gitlabWebhook.reporting.comments.mode` | Enables status notes on the originating GitLab issue or merge request (`issue`, `merge_request`, `note`, and merge-request `pipeline` events). `PerTask` (default) creates one note for each Task; `Sticky` maintains one note per TaskSpawner and item across Tasks. Requires a GitLab token: `webhookServer.sources.gitlab.tokenSecretName` on the per-source server (with `webhookServer.sources.gitlab.baseUrl` for self-hosted instances; defaults to `https://gitlab.com`), or `spec.gitlab.credentialsRef` and `spec.gitlab.apiBaseURL` on the bound [WebhookGateway](#webhookgateway) | No | | `spec.when.gitlabWebhook.gatewayRef.name` | Bind this source to a [WebhookGateway](#webhookgateway) in the same namespace whose `spec.gitlab` field is set. The per-source webhook server ignores this spawner when the reference is present | No | | `spec.when.slack.channels` | Restrict which Slack channels the bot listens in (channel IDs like `"C0123456789"`); when empty, listens in all invited channels | No | | `spec.when.slack.botMessagePolicy` | Controls whether bot-originated messages can trigger this spawner: `None` (default) rejects all bot messages, `All` allows all including self, `OthersOnly` allows other bots but rejects the bot's own output to prevent self-trigger loops | No | @@ -874,7 +874,7 @@ to receive refreshed credentials during long-running work. | `spec.when.webhook.excludeFilters[].pattern` | Exclude the delivery on a regex match against the extracted field value (mutually exclusive with `value`) | Conditional | | `spec.when.webhook.gatewayRef.name` | Bind this source to a [WebhookGateway](#webhookgateway) in the same namespace whose `spec.generic` field is set. Generic gateway deliveries remain unauthenticated, and the per-source server ignores this spawner when the reference is present | No | | `spec.when.jira.pollInterval` | Per-source poll interval (e.g., `"30s"`, `"5m"`). Defaults to `5m` when omitted | No | -| `spec.when.gitlab.baseUrl` | GitLab instance URL for API calls (e.g., `https://gitlab.example.com` or an in-cluster service URL). Defaults to the scheme and host of the workspace repo URL | No | +| `spec.when.gitlab.baseUrl` | GitLab instance URL for API calls (e.g., `https://gitlab.example.com` or an in-cluster service URL). Defaults to the scheme and host of the workspace repo URL. Required for a GitLab under a relative URL root (`https://example.com/gitlab`): its path is then excluded from the project path derived from the workspace repo URL | No | | `spec.when.gitlab.project` | Full project path to poll (`group/subgroup/project`). Defaults to the path of the workspace repo URL | No | | `spec.when.gitlab.types` | Item types to discover: `issues`, `mergeRequests`, or both (default: `issues`). Merge request work items get an `mr-` ID prefix so they never collide with issues of the same number | No | | `spec.when.gitlab.labels` | Filter items by labels; an item must carry all of them | No | @@ -1177,7 +1177,7 @@ Exactly one provider sub-struct (`spec.github`, `spec.linear`, `spec.gitlab`, or | `spec.github.credentialsRef.name` | Secret holding outbound GitHub API credentials — a `GITHUB_TOKEN` key (PAT) or GitHub App keys (`appID`, `installationID`, `privateKey`) | No | | `spec.linear.secretRef.name` | Secret holding the inbound HMAC secret (under a `webhook-secret` key) | Yes (for linear) | | `spec.gitlab.secretRef.name` | Secret holding the GitLab webhook secret token (under a `webhook-secret` key). GitLab sends the token verbatim in `X-Gitlab-Token`; deliveries whose header does not equal the stored value are rejected | Yes (for gitlab) | -| `spec.gitlab.apiBaseURL` | GitLab instance URL used for status notes (e.g. `https://gitlab.example.com` or an in-cluster Service URL). Defaults to the instance URL taken from the webhook payload | No | +| `spec.gitlab.apiBaseURL` | GitLab instance URL used for status notes (e.g. `https://gitlab.example.com` or an in-cluster Service URL). Defaults to `https://gitlab.com`. The URL is never taken from the webhook payload | No | | `spec.gitlab.credentialsRef.name` | Secret holding a GitLab access token with the `api` scope under a `GITLAB_TOKEN` key. Required for `gitlabWebhook.reporting` on spawners bound to this gateway | No | | `spec.generic` | Generic gateway configuration (no fields yet; deliveries are accepted without verification) | Conditional | | `status.path` | Derived inbound path, `/webhook//`, relative to the configured webhook host | — | diff --git a/examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml b/examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml index 0b5e88d7d..f8c5214b0 100644 --- a/examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml +++ b/examples/19-taskspawner-gitlab/taskspawner-ci-remediation.yaml @@ -8,7 +8,10 @@ spec: types: - mergeRequests # Only merge requests whose head pipeline failed. A newer failing pipeline - # on the same merge request retriggers a finished Task. + # on the same merge request retriggers a finished Task, so an agent whose + # fix keeps failing CI spawns a Task per attempt; there is no retry cap. + # Bound the cost with maxConcurrency below and by labelling merge + # requests that should stop being retried (see excludeLabels). pipelineStatus: failed excludeLabels: - no-kelos diff --git a/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml b/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml index 19c9a443e..383dd235b 100644 --- a/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml +++ b/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml @@ -15,8 +15,14 @@ spec: # TODO: Replace with your project path project: group/repo filters: - # A "/kelos fix" comment on a merge request or issue. + # A "/kelos fix" comment on a merge request or issue. Notes on commits + # and snippets carry neither a branch nor a number and are excluded so + # the branch template below never renders "kelos-issue-". - event: note + noteOn: MergeRequest + bodyPattern: '^/kelos fix' + - event: note + noteOn: Issue bodyPattern: '^/kelos fix' # A merge request pipeline failed. - event: pipeline diff --git a/hack/agent-glab-wrapper.sh b/hack/agent-glab-wrapper.sh index c003a6b11..3e9cde89b 100755 --- a/hack/agent-glab-wrapper.sh +++ b/hack/agent-glab-wrapper.sh @@ -15,10 +15,13 @@ set -u -if [ -n "${KELOS_GITLAB_TOKEN_FILE:-}" ] && [ -r "${KELOS_GITLAB_TOKEN_FILE}" ]; then - GITLAB_TOKEN=$(cat "${KELOS_GITLAB_TOKEN_FILE}") +# The startup token stays in place unless the file yields a non-empty value, +# so a Secret volume mid-rotation cannot blank the credential. +if [ -n "${KELOS_GITLAB_TOKEN_FILE:-}" ] && __kelos_token=$(cat "${KELOS_GITLAB_TOKEN_FILE}" 2>/dev/null) && [ -n "${__kelos_token}" ]; then + GITLAB_TOKEN=${__kelos_token} export GITLAB_TOKEN fi +unset __kelos_token __kelos_marker="${GLAB_CONFIG_DIR:-}/.kelos-host-configured" if [ -n "${GITLAB_HOST:-}" ] && [ -n "${GLAB_CONFIG_DIR:-}" ] && [ -n "${GITLAB_TOKEN:-}" ] && [ ! -f "${__kelos_marker}" ]; then diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index 2e33f07e6..129db7753 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -924,7 +924,7 @@ func TestBuildClaudeCodeJob_GitLabWorkspaceUsesGitLabCredentials(t *testing.T) { t.Errorf("GitHub token volume must not be mounted for a GitLab workspace") } } - if tokenVolume == nil || tokenVolume.Secret == nil || tokenVolume.Secret.SecretName != "gitlab-token" || tokenVolume.Secret.Items[0].Key != GitLabTokenSecretKey { + if tokenVolume == nil || tokenVolume.Secret == nil || tokenVolume.Secret.SecretName != "gitlab-token" || len(tokenVolume.Secret.Items) != 1 || tokenVolume.Secret.Items[0].Key != GitLabTokenSecretKey { t.Fatalf("expected GitLab token volume projecting GITLAB_TOKEN, got %+v", tokenVolume) } if !containsVolumeMount(mainContainer.VolumeMounts, GitLabTokenVolumeName, GitLabTokenMountPath) { diff --git a/internal/controller/session_controller_test.go b/internal/controller/session_controller_test.go index 925c9fccf..018852333 100644 --- a/internal/controller/session_controller_test.go +++ b/internal/controller/session_controller_test.go @@ -1715,6 +1715,55 @@ func TestPrepareSessionWorkspaceInitRefreshesCredentials(t *testing.T) { } } +func TestSessionPodUsesGitLabWorkspaceCredentials(t *testing.T) { + t.Parallel() + session := testSession("gitlab-session", "codex") + session.Spec.Worker.WorkspaceRef = &kelos.WorkspaceReference{Name: "workspace"} + workspace := &kelos.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/repo.git", + Provider: kelos.WorkspaceProviderGitLab, + SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, + } + + statefulSet, _, err := testSessionReconciler(nil, nil).buildSessionStatefulSet(session, workspace, nil) + if err != nil { + t.Fatal(err) + } + podSpec := statefulSet.Spec.Template.Spec + + tokenFile := GitLabTokenMountPath + "/" + GitLabTokenSecretKey + var gitClone *corev1.Container + for i := range podSpec.InitContainers { + if podSpec.InitContainers[i].Name == "git-clone" { + gitClone = &podSpec.InitContainers[i] + } + } + if gitClone == nil { + t.Fatalf("Session Pod has no git-clone init container: %v", podSpec.InitContainers) + } + script := strings.Join(gitClone.Command, " ") + if !strings.Contains(script, "config credential.username "+gitLabCredentialUsername) { + t.Errorf("git-clone must pin credential.username to %q for GitLab tokens: %q", gitLabCredentialUsername, script) + } + if !strings.Contains(script, tokenFile) { + t.Errorf("git-clone credential helper must read the GitLab token file %q: %q", tokenFile, script) + } + if strings.Contains(script, GitHubTokenMountPath) { + t.Errorf("git-clone must not reference the GitHub token file: %q", script) + } + + mainEnv := map[string]string{} + for _, env := range podSpec.Containers[0].Env { + mainEnv[env.Name] = env.Value + } + if mainEnv["KELOS_GITLAB_TOKEN_FILE"] != tokenFile { + t.Errorf("KELOS_GITLAB_TOKEN_FILE = %q, want %q", mainEnv["KELOS_GITLAB_TOKEN_FILE"], tokenFile) + } + if _, found := mainEnv["KELOS_GITHUB_TOKEN_FILE"]; found { + t.Error("KELOS_GITHUB_TOKEN_FILE must not be set for a GitLab workspace") + } +} + func TestSessionPluginConfigMapUsesSessionIdentity(t *testing.T) { t.Parallel() session := testSession("shared-name", "claude-code") diff --git a/internal/controller/task_controller.go b/internal/controller/task_controller.go index 6840805ba..48919ba62 100644 --- a/internal/controller/task_controller.go +++ b/internal/controller/task_controller.go @@ -337,6 +337,7 @@ func (r *TaskReconciler) createJob(ctx context.Context, task *kelos.Task) (ctrl. r.recordEvent(task, corev1.EventTypeWarning, "WorkspaceTokenMissing", "%s", err.Error()) if updateErr := r.failTaskBeforeJob(ctx, task, err.Error()); updateErr != nil { logger.Error(updateErr, "Unable to update Task status") + return ctrl.Result{}, updateErr } return ctrl.Result{}, nil } diff --git a/internal/controller/task_controller_test.go b/internal/controller/task_controller_test.go index 19d17fa4f..ddafe4afb 100644 --- a/internal/controller/task_controller_test.go +++ b/internal/controller/task_controller_test.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/json" "encoding/pem" + "errors" "net/http" "net/http/httptest" "strings" @@ -23,6 +24,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" kelos "github.com/kelos-dev/kelos/api/v1alpha2" @@ -944,6 +946,133 @@ func TestValidateSkillsAuthSecrets(t *testing.T) { } } +func TestReconcile_WorkspaceTokenPreflight(t *testing.T) { + tests := []struct { + name string + secretData map[string][]byte + wantJob bool + wantMessage string + }{ + {name: "valid token creates the Job", secretData: map[string][]byte{"GITLAB_TOKEN": []byte("glpat")}, wantJob: true}, + {name: "missing token key fails the Task", secretData: map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}, wantMessage: `secret "gitlab-token" has no GITLAB_TOKEN key`}, + {name: "blank token fails the Task", secretData: map[string][]byte{"GITLAB_TOKEN": []byte(" \n")}, wantMessage: `secret "gitlab-token" has no GITLAB_TOKEN key`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelos.AddToScheme(scheme)) + + task := &kelos.Task{ + ObjectMeta: metav1.ObjectMeta{Name: "gl-task", Namespace: "default", Finalizers: []string{taskFinalizer}}, + Spec: kelos.TaskSpec{ + Type: AgentTypeCodex, + Prompt: "test", + Credentials: &kelos.Credentials{Type: kelos.CredentialTypeNone}, + WorkspaceRef: &kelos.WorkspaceReference{Name: "workspace"}, + }, + } + workspace := &kelos.Workspace{ + ObjectMeta: metav1.ObjectMeta{Name: "workspace", Namespace: "default"}, + Spec: kelos.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/repo.git", + Provider: kelos.WorkspaceProviderGitLab, + SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, + }, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gitlab-token", Namespace: "default"}, + Data: tt.secretData, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(task). + WithObjects(task, workspace, secret). + Build() + builder := NewJobBuilder() + builder.CodexImage = "codex:test" + r := &TaskReconciler{Client: cl, Scheme: scheme, JobBuilder: builder} + + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(task)}); err != nil { + t.Fatalf("Reconcile() error: %v", err) + } + + var jobs batchv1.JobList + if err := cl.List(context.Background(), &jobs, client.InNamespace(task.Namespace)); err != nil { + t.Fatalf("listing Jobs: %v", err) + } + if (len(jobs.Items) > 0) != tt.wantJob { + t.Fatalf("Jobs = %d, want job created = %v", len(jobs.Items), tt.wantJob) + } + + updated := &kelos.Task{} + if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), updated); err != nil { + t.Fatalf("getting updated task: %v", err) + } + if tt.wantJob { + if updated.Status.Phase == kelos.TaskPhaseFailed { + t.Fatalf("task unexpectedly failed: %s", updated.Status.Message) + } + return + } + if updated.Status.Phase != kelos.TaskPhaseFailed { + t.Fatalf("task phase = %q, want %q", updated.Status.Phase, kelos.TaskPhaseFailed) + } + if !strings.Contains(updated.Status.Message, tt.wantMessage) { + t.Fatalf("task message = %q, want containing %q", updated.Status.Message, tt.wantMessage) + } + }) + } +} + +func TestReconcile_WorkspaceTokenPreflightRetriesFailedStatusUpdate(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelos.AddToScheme(scheme)) + + task := &kelos.Task{ + ObjectMeta: metav1.ObjectMeta{Name: "gl-task", Namespace: "default", Finalizers: []string{taskFinalizer}}, + Spec: kelos.TaskSpec{ + Type: AgentTypeCodex, + Prompt: "test", + WorkspaceRef: &kelos.WorkspaceReference{Name: "workspace"}, + }, + } + workspace := &kelos.Workspace{ + ObjectMeta: metav1.ObjectMeta{Name: "workspace", Namespace: "default"}, + Spec: kelos.WorkspaceSpec{ + Repo: "https://gitlab.example.com/group/repo.git", + Provider: kelos.WorkspaceProviderGitLab, + SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, + }, + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gitlab-token", Namespace: "default"}} + statusErr := errors.New("status update failed") + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(task). + WithObjects(task, workspace, secret). + WithInterceptorFuncs(interceptor.Funcs{ + SubResourceUpdate: func(context.Context, client.Client, string, client.Object, ...client.SubResourceUpdateOption) error { + return statusErr + }, + }). + Build() + r := &TaskReconciler{Client: cl, Scheme: scheme} + + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(task)}) + if !errors.Is(err, statusErr) { + t.Fatalf("Reconcile() error = %v, want the status update error so the reconcile is retried", err) + } + var jobs batchv1.JobList + if err := cl.List(context.Background(), &jobs, client.InNamespace(task.Namespace)); err != nil { + t.Fatalf("listing Jobs: %v", err) + } + if len(jobs.Items) != 0 { + t.Fatalf("Jobs = %d, want none", len(jobs.Items)) + } +} + func TestFailTaskBeforeJobReleasesBranchLock(t *testing.T) { scheme := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(scheme)) diff --git a/internal/controller/taskspawner_controller.go b/internal/controller/taskspawner_controller.go index 99773a4d5..3bf18fd68 100644 --- a/internal/controller/taskspawner_controller.go +++ b/internal/controller/taskspawner_controller.go @@ -243,7 +243,7 @@ func (r *TaskSpawnerReconciler) resolveTaskSpawnerWorkspace(ctx context.Context, return nil, workspaceRef, false, ctrl.Result{}, err } else { secretData = secret.Data - isGitHubApp = githubapp.IsGitHubApp(secret.Data) + isGitHubApp = workspaceProviderFor(workspace).name == kelos.WorkspaceProviderGitHub && githubapp.IsGitHubApp(secret.Data) if isGitHubApp { logger.Info("Detected GitHub App secret for TaskSpawner", "secret", workspace.SecretRef.Name) } diff --git a/internal/controller/taskspawner_controller_test.go b/internal/controller/taskspawner_controller_test.go index b8935bbef..deae45c5a 100644 --- a/internal/controller/taskspawner_controller_test.go +++ b/internal/controller/taskspawner_controller_test.go @@ -419,9 +419,16 @@ func TestReconcileDeploymentGitLabWorkspaceInjectsGitLabToken(t *testing.T) { SecretRef: &kelos.SecretReference{Name: "gitlab-token"}, }, } + // GitHub App keys alongside the token must not switch the spawner to the + // GitHub App credential branch: only the provider decides. secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "gitlab-token", Namespace: "default"}, - Data: map[string][]byte{"GITLAB_TOKEN": []byte("glpat")}, + Data: map[string][]byte{ + "GITLAB_TOKEN": []byte("glpat"), + "appID": []byte("1"), + "installationID": []byte("2"), + "privateKey": []byte("key"), + }, } cl := fake.NewClientBuilder(). diff --git a/internal/controller/taskspawner_deployment_builder.go b/internal/controller/taskspawner_deployment_builder.go index 3ca611772..b435bf5de 100644 --- a/internal/controller/taskspawner_deployment_builder.go +++ b/internal/controller/taskspawner_deployment_builder.go @@ -379,11 +379,18 @@ func taskSpawnerNeedsWorkspaceToken(ts *kelos.TaskSpawner, ghProxyConfigured boo // gitLabSourceArgs returns the spawner flags for a GitLab source. The // instance URL and project path default to the workspace repo URL and are -// individually overridable from the source spec. +// individually overridable from the source spec. A BaseURL carrying a path +// (a GitLab under a relative URL root) marks that path as instance prefix +// rather than project path. func gitLabSourceArgs(gl *kelos.GitLab, workspaceRepo string) []string { baseURL, project := parseGitLabRepo(workspaceRepo) if gl.BaseURL != "" { baseURL = gl.BaseURL + if u, err := url.Parse(gl.BaseURL); err == nil { + if prefix := strings.Trim(u.Path, "/"); prefix != "" { + project = strings.TrimPrefix(project, prefix+"/") + } + } } if gl.Project != "" { project = gl.Project @@ -396,9 +403,9 @@ func gitLabSourceArgs(gl *kelos.GitLab, workspaceRepo string) []string { // parseGitLabRepo splits a GitLab repository URL into the instance base URL // and the full project path, e.g. https://gitlab.example.com/group/sub/repo.git -// yields ("https://gitlab.example.com", "group/sub/repo"). SSH URLs -// (git@host:group/repo.git) map to an https base URL. Any username in the URL -// is dropped. +// yields ("https://gitlab.example.com", "group/sub/repo"). SSH (git@host:...) +// and git:// URLs map to an https base URL because the API is only reachable +// over HTTP. Any username in the URL is dropped. func parseGitLabRepo(repoURL string) (baseURL, project string) { repoURL = strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(repoURL), "/"), ".git") @@ -412,7 +419,11 @@ func parseGitLabRepo(repoURL string) (baseURL, project string) { if err != nil || parsed.Host == "" { return "", strings.Trim(repoURL, "/") } - return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String(), strings.Trim(parsed.Path, "/") + scheme := parsed.Scheme + if scheme != "http" && scheme != "https" { + scheme = "https" + } + return (&url.URL{Scheme: scheme, Host: parsed.Host}).String(), strings.Trim(parsed.Path, "/") } func gitHubReportingNeedsToken(reporting *kelos.GitHubReporting) bool { diff --git a/internal/controller/taskspawner_deployment_builder_test.go b/internal/controller/taskspawner_deployment_builder_test.go index 639b90699..9697cc5b4 100644 --- a/internal/controller/taskspawner_deployment_builder_test.go +++ b/internal/controller/taskspawner_deployment_builder_test.go @@ -737,6 +737,7 @@ func TestParseGitLabRepo(t *testing.T) { {"https://oauth2@gitlab.example.com/group/repo", "https://gitlab.example.com", "group/repo"}, {"http://gitlab-webservice-default.gitlab.svc:8181/group/repo.git", "http://gitlab-webservice-default.gitlab.svc:8181", "group/repo"}, {"git@gitlab.example.com:group/sub/repo.git", "https://gitlab.example.com", "group/sub/repo"}, + {"git://gitlab.example.com/group/repo.git", "https://gitlab.example.com", "group/repo"}, {"group/repo", "", "group/repo"}, } for _, tt := range tests { @@ -787,6 +788,42 @@ func TestDeploymentBuilder_GitLab(t *testing.T) { } } +func TestGitLabSourceArgsRelativeURLRoot(t *testing.T) { + tests := []struct { + name string + gl kelos.GitLab + repo string + want []string + }{ + { + name: "base url path is stripped from the derived project", + gl: kelos.GitLab{BaseURL: "https://example.com/gitlab"}, + repo: "https://example.com/gitlab/group/repo.git", + want: []string{"--gitlab-base-url=https://example.com/gitlab", "--gitlab-project=group/repo"}, + }, + { + name: "explicit project wins over derivation", + gl: kelos.GitLab{BaseURL: "https://example.com/gitlab/", Project: "other/repo"}, + repo: "https://example.com/gitlab/group/repo.git", + want: []string{"--gitlab-base-url=https://example.com/gitlab/", "--gitlab-project=other/repo"}, + }, + { + name: "base url without path leaves the project untouched", + gl: kelos.GitLab{BaseURL: "http://gitlab.svc:8181"}, + repo: "https://example.com/group/repo.git", + want: []string{"--gitlab-base-url=http://gitlab.svc:8181", "--gitlab-project=group/repo"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gitLabSourceArgs(&tt.gl, tt.repo) + if strings.Join(got, " ") != strings.Join(tt.want, " ") { + t.Errorf("gitLabSourceArgs() = %v, want %v", got, tt.want) + } + }) + } +} + func TestDeploymentBuilder_GitLabOverrides(t *testing.T) { builder := NewDeploymentBuilder() ts := &kelos.TaskSpawner{ diff --git a/internal/controller/webhookgateway_controller.go b/internal/controller/webhookgateway_controller.go index 76f20819f..f30a5c736 100644 --- a/internal/controller/webhookgateway_controller.go +++ b/internal/controller/webhookgateway_controller.go @@ -1,6 +1,7 @@ package controller import ( + "bytes" "context" "fmt" "time" @@ -19,6 +20,10 @@ import ( kelos "github.com/kelos-dev/kelos/api/v1alpha2" ) +// gatewayWebhookSecretKey is the Secret data key the webhook server reads the +// inbound verification secret from. +const gatewayWebhookSecretKey = "webhook-secret" + // WebhookGatewayReconciler reconciles WebhookGateway status. It derives the // inbound URL and reflects the authentication state based on the gateway type // and the presence of its referenced Secrets. It manages no workloads. @@ -94,28 +99,28 @@ func (r *WebhookGatewayReconciler) evaluate(ctx context.Context, gw *kelos.Webho case gw.Spec.GitHub != nil: // Inbound HMAC secret, then optionally the outbound API credentials. - if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitHub.SecretRef.Name, "HMAC secret"); err != nil || phase != "" { + if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitHub.SecretRef.Name, "HMAC secret", ""); err != nil || phase != "" { return phase, msg, requeue, err } if gw.Spec.GitHub.CredentialsRef != nil { - if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitHub.CredentialsRef.Name, "credentials secret"); err != nil || phase != "" { + if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitHub.CredentialsRef.Name, "credentials secret", ""); err != nil || phase != "" { return phase, msg, requeue, err } } return kelos.WebhookGatewayPhaseAuthenticated, "", false, nil case gw.Spec.Linear != nil: - if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.Linear.SecretRef.Name, "HMAC secret"); err != nil || phase != "" { + if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.Linear.SecretRef.Name, "HMAC secret", ""); err != nil || phase != "" { return phase, msg, requeue, err } return kelos.WebhookGatewayPhaseAuthenticated, "", false, nil case gw.Spec.GitLab != nil: - if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitLab.SecretRef.Name, "webhook token secret"); err != nil || phase != "" { + if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitLab.SecretRef.Name, "webhook token secret", gatewayWebhookSecretKey); err != nil || phase != "" { return phase, msg, requeue, err } if gw.Spec.GitLab.CredentialsRef != nil { - if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitLab.CredentialsRef.Name, "credentials secret"); err != nil || phase != "" { + if phase, msg, requeue, err := r.checkSecret(ctx, gw.Namespace, gw.Spec.GitLab.CredentialsRef.Name, "credentials secret", GitLabTokenSecretKey); err != nil || phase != "" { return phase, msg, requeue, err } } @@ -129,29 +134,23 @@ func (r *WebhookGatewayReconciler) evaluate(ctx context.Context, gw *kelos.Webho } // checkSecret returns a SecretMissing phase (with a requeue) when the named -// Secret is absent, or an empty phase when it is present. -func (r *WebhookGatewayReconciler) checkSecret(ctx context.Context, namespace, name, kind string) (kelos.WebhookGatewayPhase, string, bool, error) { - missing, err := r.secretMissing(ctx, namespace, name) - if err != nil { - return "", "", false, err - } - if missing { - return kelos.WebhookGatewayPhaseSecretMissing, - fmt.Sprintf("%s %q not found", kind, name), true, nil - } - return "", "", false, nil -} - -func (r *WebhookGatewayReconciler) secretMissing(ctx context.Context, namespace, name string) (bool, error) { +// Secret is absent or, when key is set, lacks a non-blank value under that +// key. An empty phase means the Secret is usable. +func (r *WebhookGatewayReconciler) checkSecret(ctx context.Context, namespace, name, kind, key string) (kelos.WebhookGatewayPhase, string, bool, error) { var secret corev1.Secret err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &secret) if apierrors.IsNotFound(err) { - return true, nil + return kelos.WebhookGatewayPhaseSecretMissing, + fmt.Sprintf("%s %q not found", kind, name), true, nil } if err != nil { - return false, err + return "", "", false, err + } + if key != "" && len(bytes.TrimSpace(secret.Data[key])) == 0 { + return kelos.WebhookGatewayPhaseSecretMissing, + fmt.Sprintf("%s %q has no %s key", kind, name, key), true, nil } - return false, nil + return "", "", false, nil } // SetupWithManager sets up the controller with the Manager. diff --git a/internal/controller/webhookgateway_controller_test.go b/internal/controller/webhookgateway_controller_test.go index a32d4a8fb..8bf80fc5f 100644 --- a/internal/controller/webhookgateway_controller_test.go +++ b/internal/controller/webhookgateway_controller_test.go @@ -139,7 +139,10 @@ func TestWebhookGatewayReconciler_GitLabAuthenticated(t *testing.T) { }, }, } - secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}} + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}, + Data: map[string][]byte{gatewayWebhookSecretKey: []byte("token")}, + } got, _ := reconcileGateway(t, gw, secret) if got.Status.Phase != kelos.WebhookGatewayPhaseAuthenticated { t.Errorf("phase = %q, want Authenticated", got.Status.Phase) @@ -149,6 +152,65 @@ func TestWebhookGatewayReconciler_GitLabAuthenticated(t *testing.T) { } } +func TestWebhookGatewayReconciler_GitLabSecretMissingKey(t *testing.T) { + // The webhook handler rejects every delivery when the key is absent, so + // the gateway must not report Authenticated. + tests := []struct { + name string + secret *corev1.Secret + creds *corev1.Secret + want string + }{ + { + name: "webhook secret without key", + secret: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}}, + want: `webhook token secret "gl-secret" has no webhook-secret key`, + }, + { + name: "blank webhook secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}, + Data: map[string][]byte{gatewayWebhookSecretKey: []byte(" \n")}, + }, + want: `webhook token secret "gl-secret" has no webhook-secret key`, + }, + { + name: "credentials without token key", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}, + Data: map[string][]byte{gatewayWebhookSecretKey: []byte("token")}, + }, + creds: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gl-creds", Namespace: "default"}}, + want: `credentials secret "gl-creds" has no GITLAB_TOKEN key`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gw := &kelos.WebhookGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, + Spec: kelos.WebhookGatewaySpec{GitLab: &kelos.GitLabGateway{ + SecretRef: kelos.SecretReference{Name: "gl-secret"}, + }}, + } + objs := []client.Object{gw, tt.secret} + if tt.creds != nil { + gw.Spec.GitLab.CredentialsRef = &kelos.SecretReference{Name: tt.creds.Name} + objs = append(objs, tt.creds) + } + got, res := reconcileGateway(t, objs...) + if got.Status.Phase != kelos.WebhookGatewayPhaseSecretMissing { + t.Errorf("phase = %q, want SecretMissing", got.Status.Phase) + } + if got.Status.Message != tt.want { + t.Errorf("message = %q, want %q", got.Status.Message, tt.want) + } + if res.RequeueAfter == 0 { + t.Error("expected requeue until the key is added") + } + }) + } +} + func TestWebhookGatewayReconciler_GitLabCredentialsAbsent(t *testing.T) { gw := &kelos.WebhookGateway{ ObjectMeta: metav1.ObjectMeta{Name: "gl", Namespace: "default"}, @@ -159,7 +221,10 @@ func TestWebhookGatewayReconciler_GitLabCredentialsAbsent(t *testing.T) { }, }, } - secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}} + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "gl-secret", Namespace: "default"}, + Data: map[string][]byte{gatewayWebhookSecretKey: []byte("token")}, + } got, res := reconcileGateway(t, gw, secret) if got.Status.Phase != kelos.WebhookGatewayPhaseSecretMissing { t.Errorf("phase = %q, want SecretMissing for absent credentials", got.Status.Phase) diff --git a/internal/controller/workspace_provider.go b/internal/controller/workspace_provider.go index a677bf79e..8da3dc33b 100644 --- a/internal/controller/workspace_provider.go +++ b/internal/controller/workspace_provider.go @@ -7,6 +7,7 @@ import ( corev1 "k8s.io/api/core/v1" kelos "github.com/kelos-dev/kelos/api/v1alpha2" + "github.com/kelos-dev/kelos/internal/githubapp" ) const ( @@ -167,10 +168,10 @@ func (p workspaceProvider) tokenVolumeMount() corev1.VolumeMount { } // workspaceSecretTokenError reports a workspace Secret that lacks the token -// key its provider requires. GitHub is exempt because GitHub App secrets -// legitimately carry appID/installationID/privateKey instead of a token. +// key its provider requires. A GitHub App secret (appID/installationID/ +// privateKey) satisfies the GitHub provider without a token key. func workspaceSecretTokenError(p workspaceProvider, secretName string, data map[string][]byte) error { - if p.name == kelos.WorkspaceProviderGitHub { + if p.name == kelos.WorkspaceProviderGitHub && githubapp.IsGitHubApp(data) { return nil } if len(bytes.TrimSpace(data[p.secretKey])) == 0 { diff --git a/internal/controller/workspace_provider_test.go b/internal/controller/workspace_provider_test.go index 16c8a463b..facf94516 100644 --- a/internal/controller/workspace_provider_test.go +++ b/internal/controller/workspace_provider_test.go @@ -130,13 +130,21 @@ func TestWorkspaceSecretTokenError(t *testing.T) { github := workspaceProviderFor(&kelos.WorkspaceSpec{}) gitlab := workspaceProviderFor(&kelos.WorkspaceSpec{Provider: kelos.WorkspaceProviderGitLab}) - if err := workspaceSecretTokenError(github, "app", map[string][]byte{"appID": []byte("1")}); err != nil { - t.Errorf("github secrets are exempt from the token check, got %v", err) + githubApp := map[string][]byte{"appID": []byte("1"), "installationID": []byte("2"), "privateKey": []byte("key")} + if err := workspaceSecretTokenError(github, "app", githubApp); err != nil { + t.Errorf("github app secrets satisfy the provider without a token key, got %v", err) + } + if err := workspaceSecretTokenError(github, "gh", map[string][]byte{"GITHUB_TOKEN": []byte("ghp")}); err != nil { + t.Errorf("unexpected error for a valid github token secret: %v", err) + } + err := workspaceSecretTokenError(github, "partial", map[string][]byte{"appID": []byte("1")}) + if err == nil || !strings.Contains(err.Error(), `secret "partial" has no GITHUB_TOKEN key`) { + t.Errorf("a partial GitHub App secret must not pass as a token secret, got %v", err) } if err := workspaceSecretTokenError(gitlab, "gl", map[string][]byte{"GITLAB_TOKEN": []byte("glpat")}); err != nil { t.Errorf("unexpected error for a valid gitlab secret: %v", err) } - err := workspaceSecretTokenError(gitlab, "gl", map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}) + err = workspaceSecretTokenError(gitlab, "gl", map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}) if err == nil || !strings.Contains(err.Error(), `secret "gl" has no GITLAB_TOKEN key`) { t.Errorf("GITHUB_TOKEN must not stand in for GITLAB_TOKEN, got %v", err) } @@ -160,7 +168,8 @@ func TestValidateTaskSpawnerWorkspace(t *testing.T) { {name: "gitlab source with gitlab workspace", when: kelos.When{GitLab: &kelos.GitLab{}}, workspace: gitlabWS, data: gitlabData}, {name: "gitlab webhook with gitlab workspace", when: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{}}, workspace: gitlabWS, data: gitlabData}, {name: "github issues with github workspace", when: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, workspace: githubWS, data: map[string][]byte{"GITHUB_TOKEN": []byte("ghp")}}, - {name: "github app secret without token key", when: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, workspace: githubWS, data: map[string][]byte{"appID": []byte("1")}}, + {name: "github app secret without token key", when: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, workspace: githubWS, data: map[string][]byte{"appID": []byte("1"), "installationID": []byte("2"), "privateKey": []byte("key")}}, + {name: "github secret with neither app keys nor token", when: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, workspace: githubWS, data: map[string][]byte{"appID": []byte("1")}, wantErr: `secret "gh" has no GITHUB_TOKEN key`}, {name: "cron works with any provider", when: kelos.When{Cron: &kelos.Cron{Schedule: "@hourly"}}, workspace: gitlabWS, data: gitlabData}, {name: "jira with gitlab workspace missing token", when: kelos.When{Jira: &kelos.Jira{}}, workspace: gitlabWS, data: map[string][]byte{}, wantErr: "has no GITLAB_TOKEN key"}, {name: "gitlab source with github workspace", when: kelos.When{GitLab: &kelos.GitLab{}}, workspace: githubWS, data: map[string][]byte{"GITHUB_TOKEN": []byte("glpat")}, wantErr: "requires a Workspace with provider gitlab, but the Workspace provider is github"}, diff --git a/internal/conversion/taskspawner.go b/internal/conversion/taskspawner.go index 10408d548..356d29711 100644 --- a/internal/conversion/taskspawner.go +++ b/internal/conversion/taskspawner.go @@ -3,7 +3,6 @@ package conversion import ( "context" "encoding/json" - "fmt" v1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" v1alpha2 "github.com/kelos-dev/kelos/api/v1alpha2" @@ -88,9 +87,7 @@ func taskSpawnerToHub(_ context.Context, src *v1alpha1.TaskSpawner, dst *v1alpha deleteAnnotation(dst.Annotations, preservedGitHubCommentsReportingAnnotation) restorePreservedWebhookGatewayRefs(src.Annotations, &dst.Spec.When) deleteAnnotation(dst.Annotations, preservedWebhookGatewayRefsAnnotation) - if err := restorePreservedGitLabSources(src.Annotations, &dst.Spec.When); err != nil { - return err - } + restorePreservedGitLabSources(src.Annotations, &dst.Spec.When) deleteAnnotation(dst.Annotations, preservedGitLabSourcesAnnotation) return nil } @@ -185,18 +182,19 @@ func setPreservedGitLabSources(dst *v1alpha1.TaskSpawner, when v1alpha2.When) er return nil } -func restorePreservedGitLabSources(annotations map[string]string, when *v1alpha2.When) error { +func restorePreservedGitLabSources(annotations map[string]string, when *v1alpha2.When) { raw, ok := annotations[preservedGitLabSourcesAnnotation] if !ok || raw == "" || whenHasSource(*when) { - return nil + return } var preserved preservedGitLabSources if err := json.Unmarshal([]byte(raw), &preserved); err != nil { - return fmt.Errorf("decoding %s annotation: %w", preservedGitLabSourcesAnnotation, err) + // The annotation is best-effort preservation data and can be set by + // users; malformed data must not block API version conversion. + return } when.GitLab = preserved.GitLab when.GitLabWebhook = preserved.GitLabWebhook - return nil } func whenHasSource(when v1alpha2.When) bool { diff --git a/internal/conversion/taskspawner_test.go b/internal/conversion/taskspawner_test.go index 7933ff77e..0031553e3 100644 --- a/internal/conversion/taskspawner_test.go +++ b/internal/conversion/taskspawner_test.go @@ -509,7 +509,7 @@ func TestTaskSpawnerConvert_GitHubCommentsReportingRoundTrips(t *testing.T) { t.Run(tt.name, func(t *testing.T) { hub := &v1alpha2.TaskSpawner{ObjectMeta: metav1.ObjectMeta{Name: "reporter", Namespace: "default"}} tt.configureHub(&hub.Spec.When, &v1alpha2.GitHubReporting{ - Comments: &v1alpha2.GitHubCommentsReporting{Mode: v1alpha2.GitHubCommentModeSticky}, + Comments: &v1alpha2.GitHubCommentsReporting{Mode: v1alpha2.CommentModeSticky}, }) spoke := &v1alpha1.TaskSpawner{} @@ -528,7 +528,7 @@ func TestTaskSpawnerConvert_GitHubCommentsReportingRoundTrips(t *testing.T) { t.Fatalf("taskSpawnerToHub() error = %v", err) } reporting := tt.roundTripReporting(&back.Spec.When) - if reporting.Comments == nil || reporting.Comments.Mode != v1alpha2.GitHubCommentModeSticky { + if reporting.Comments == nil || reporting.Comments.Mode != v1alpha2.CommentModeSticky { t.Fatalf("round-tripped comments = %#v, want Sticky", reporting.Comments) } if reporting.Enabled { @@ -548,7 +548,7 @@ func TestTaskSpawnerConvert_V1Alpha1CanDisablePreservedCommentsReporting(t *test When: v1alpha2.When{ GitHubWebhook: &v1alpha2.GitHubWebhook{ Reporting: &v1alpha2.GitHubReporting{ - Comments: &v1alpha2.GitHubCommentsReporting{Mode: v1alpha2.GitHubCommentModeSticky}, + Comments: &v1alpha2.GitHubCommentsReporting{Mode: v1alpha2.CommentModeSticky}, }, }, }, @@ -856,6 +856,28 @@ func TestTaskSpawnerToHub_EditedV1Alpha1SourceReplacesPreservedGitLab(t *testing } } +func TestTaskSpawnerToHub_MalformedGitLabAnnotationDoesNotBlockConversion(t *testing.T) { + spoke := &v1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + preservedGitLabSourcesAnnotation: `{"gitlab":`, + }}, + Spec: v1alpha1.TaskSpawnerSpec{When: v1alpha1.When{Cron: &v1alpha1.Cron{Schedule: "@hourly"}}}, + } + hub := &v1alpha2.TaskSpawner{} + if err := taskSpawnerToHub(context.Background(), spoke, hub); err != nil { + t.Fatalf("taskSpawnerToHub() error = %v, want malformed preservation data ignored", err) + } + if hub.Spec.When.GitLab != nil || hub.Spec.When.GitLabWebhook != nil { + t.Fatalf("GitLab sources should not be restored from a malformed annotation, got %+v", hub.Spec.When) + } + if hub.Spec.When.Cron == nil { + t.Fatal("expected cron source to survive") + } + if _, ok := hub.Annotations[preservedGitLabSourcesAnnotation]; ok { + t.Fatal("malformed preservation annotation leaked onto hub object") + } +} + func TestTaskSpawnerFromHub_NoGitLabSourceOmitsAnnotation(t *testing.T) { hub := &v1alpha2.TaskSpawner{Spec: v1alpha2.TaskSpawnerSpec{When: v1alpha2.When{ Cron: &v1alpha2.Cron{Schedule: "@hourly"}, diff --git a/internal/conversion/workspace.go b/internal/conversion/workspace.go index 965489bf0..a7a0aa6af 100644 --- a/internal/conversion/workspace.go +++ b/internal/conversion/workspace.go @@ -18,8 +18,10 @@ func workspaceToHub(_ context.Context, src *v1alpha1.Workspace, dst *v1alpha2.Wo if err := convertViaJSON(&src.Spec, &dst.Spec); err != nil { return err } - if provider := src.Annotations[preservedWorkspaceProviderAnnotation]; provider != "" { - dst.Spec.Provider = provider + // Only the non-default provider is restored; anything else (github, or an + // edited value) leaves the field empty so the v1alpha2 default applies. + if src.Annotations[preservedWorkspaceProviderAnnotation] == v1alpha2.WorkspaceProviderGitLab { + dst.Spec.Provider = v1alpha2.WorkspaceProviderGitLab } deleteAnnotation(dst.Annotations, preservedWorkspaceProviderAnnotation) return nil diff --git a/internal/conversion/workspace_test.go b/internal/conversion/workspace_test.go index b9bed287a..55852d36a 100644 --- a/internal/conversion/workspace_test.go +++ b/internal/conversion/workspace_test.go @@ -44,13 +44,18 @@ func TestWorkspaceConvert_ProviderRoundTrips(t *testing.T) { func TestWorkspaceFromHub_GitHubProviderOmitsAnnotation(t *testing.T) { for _, provider := range []string{"", v1alpha2.WorkspaceProviderGitHub} { - hub := &v1alpha2.Workspace{Spec: v1alpha2.WorkspaceSpec{ - Repo: "https://github.com/org/repo.git", - Provider: provider, - }} - spoke := &v1alpha1.Workspace{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ - preservedWorkspaceProviderAnnotation: "gitlab", - }}} + // A stale annotation on the hub (left by an earlier gitlab round-trip) + // is copied onto the spoke before the provider is inspected. + hub := &v1alpha2.Workspace{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + preservedWorkspaceProviderAnnotation: "gitlab", + }}, + Spec: v1alpha2.WorkspaceSpec{ + Repo: "https://github.com/org/repo.git", + Provider: provider, + }, + } + spoke := &v1alpha1.Workspace{} if err := workspaceFromHub(context.Background(), hub, spoke); err != nil { t.Fatalf("workspaceFromHub() error = %v", err) } @@ -70,3 +75,22 @@ func TestWorkspaceToHub_WithoutAnnotationLeavesProviderEmpty(t *testing.T) { t.Fatalf("provider = %q, want empty so the CRD default applies", hub.Spec.Provider) } } + +func TestWorkspaceToHub_IgnoresUnsupportedAnnotationValues(t *testing.T) { + for _, value := range []string{v1alpha2.WorkspaceProviderGitHub, "bitbucket", " "} { + spoke := &v1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{preservedWorkspaceProviderAnnotation: value}}, + Spec: v1alpha1.WorkspaceSpec{Repo: "https://github.com/org/repo.git"}, + } + hub := &v1alpha2.Workspace{} + if err := workspaceToHub(context.Background(), spoke, hub); err != nil { + t.Fatalf("workspaceToHub() error = %v", err) + } + if hub.Spec.Provider != "" { + t.Fatalf("annotation %q set provider = %q, want empty so the CRD default applies", value, hub.Spec.Provider) + } + if _, ok := hub.Annotations[preservedWorkspaceProviderAnnotation]; ok { + t.Fatalf("annotation %q remained on hub", value) + } + } +} diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml index e83d450f2..40772b0cc 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml @@ -23187,6 +23187,9 @@ spec: BaseURL overrides the GitLab instance URL used for API calls (for example "https://gitlab.example.com" or an in-cluster service URL). When empty, the scheme and host of the workspace repo URL are used. + Set it for a GitLab served under a relative URL root + ("https://example.com/gitlab"); the path is then excluded from the + project path derived from the workspace repo URL. pattern: ^https?://.+ type: string commentPolicy: @@ -23301,6 +23304,9 @@ spec: Types specifies which item types to discover: "issues", "mergeRequests", or both. items: + enum: + - issues + - mergeRequests type: string type: array type: object @@ -23314,6 +23320,13 @@ spec: payload object_kind: "issue", "merge_request", "note", "pipeline", "push", or "tag_push". items: + enum: + - issue + - merge_request + - note + - pipeline + - push + - tag_push type: string minItems: 1 type: array diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml index 63131a102..b939569dc 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/webhookgateway-crd.yaml @@ -104,7 +104,7 @@ spec: description: |- APIBaseURL is the GitLab instance URL used for status reporting (for example "https://gitlab.example.com" or an in-cluster service URL). When - empty, the instance URL is taken from the originating webhook payload. + empty, "https://gitlab.com" is used. pattern: ^https?://.+ type: string credentialsRef: diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml index 0b75de7d8..a2cd7dd56 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/workspace-crd.yaml @@ -257,9 +257,10 @@ spec: type: string secretRef: description: |- - SecretRef references a Secret containing the Provider's token key - (GITHUB_TOKEN for github, GITLAB_TOKEN for gitlab) for git - authentication and CLI operations. + SecretRef references a Secret containing credentials for the Provider. + For github (or when Provider is omitted), it must contain GITHUB_TOKEN + or GitHub App keys (appID, installationID, privateKey). + For gitlab, it must contain GITLAB_TOKEN. properties: name: description: Name is the name of the secret. diff --git a/internal/manifests/charts/kelos/templates/webhook-server.yaml b/internal/manifests/charts/kelos/templates/webhook-server.yaml index a50e0b953..511d803b1 100644 --- a/internal/manifests/charts/kelos/templates/webhook-server.yaml +++ b/internal/manifests/charts/kelos/templates/webhook-server.yaml @@ -293,11 +293,14 @@ spec: - --webhook-bind-address=:8443 - --metrics-bind-address=:8080 - --health-probe-bind-address=:8081 + {{- if .Values.webhookServer.sources.gitlab.baseUrl }} + - --gitlab-base-url={{ .Values.webhookServer.sources.gitlab.baseUrl }} + {{- end }} env: - name: WEBHOOK_SECRET valueFrom: secretKeyRef: - name: {{ .Values.webhookServer.sources.gitlab.secretName }} + name: {{ required "webhookServer.sources.gitlab.secretName must be set when webhookServer.sources.gitlab.enabled is true" .Values.webhookServer.sources.gitlab.secretName }} key: WEBHOOK_SECRET {{- if .Values.webhookServer.sources.gitlab.tokenSecretName }} - name: GITLAB_TOKEN diff --git a/internal/manifests/charts/kelos/values.yaml b/internal/manifests/charts/kelos/values.yaml index 22d3a91a1..dad6e47ad 100644 --- a/internal/manifests/charts/kelos/values.yaml +++ b/internal/manifests/charts/kelos/values.yaml @@ -129,6 +129,10 @@ webhookServer: # scope). Enables status notes on the originating issue or merge request # for spawners that set gitlabWebhook.reporting. tokenSecretName: "" + # GitLab instance URL that status notes are posted to (for example + # https://gitlab.example.com or an in-cluster Service URL). Defaults to + # https://gitlab.com. The URL is never taken from webhook payloads. + baseUrl: "" service: # Service type for the webhook endpoint. # Allowed values: ClusterIP, LoadBalancer, NodePort. diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 8be2100cd..dd8234fa8 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -60490,6 +60490,9 @@ spec: BaseURL overrides the GitLab instance URL used for API calls (for example "https://gitlab.example.com" or an in-cluster service URL). When empty, the scheme and host of the workspace repo URL are used. + Set it for a GitLab served under a relative URL root + ("https://example.com/gitlab"); the path is then excluded from the + project path derived from the workspace repo URL. pattern: ^https?://.+ type: string commentPolicy: @@ -60604,6 +60607,9 @@ spec: Types specifies which item types to discover: "issues", "mergeRequests", or both. items: + enum: + - issues + - mergeRequests type: string type: array type: object @@ -60617,6 +60623,13 @@ spec: payload object_kind: "issue", "merge_request", "note", "pipeline", "push", or "tag_push". items: + enum: + - issue + - merge_request + - note + - pipeline + - push + - tag_push type: string minItems: 1 type: array @@ -61284,7 +61297,7 @@ spec: description: |- APIBaseURL is the GitLab instance URL used for status reporting (for example "https://gitlab.example.com" or an in-cluster service URL). When - empty, the instance URL is taken from the originating webhook payload. + empty, "https://gitlab.com" is used. pattern: ^https?://.+ type: string credentialsRef: @@ -68894,9 +68907,10 @@ spec: type: string secretRef: description: |- - SecretRef references a Secret containing the Provider's token key - (GITHUB_TOKEN for github, GITLAB_TOKEN for gitlab) for git - authentication and CLI operations. + SecretRef references a Secret containing credentials for the Provider. + For github (or when Provider is omitted), it must contain GITHUB_TOKEN + or GitHub App keys (appID, installationID, privateKey). + For gitlab, it must contain GITLAB_TOKEN. properties: name: description: Name is the name of the secret. diff --git a/internal/reporting/watcher.go b/internal/reporting/watcher.go index 02d3f7fb2..6abfcc054 100644 --- a/internal/reporting/watcher.go +++ b/internal/reporting/watcher.go @@ -45,8 +45,9 @@ const ( AnnotationSourceProvider = "kelos.dev/source-provider" // AnnotationSourceBaseURL records the instance URL of the tracker the event - // came from (e.g. "https://gitlab.example.com"), so reporting can target - // self-hosted instances without server-side configuration. For GitLab, + // came from (e.g. "https://gitlab.example.com"). It is informational: + // reporting resolves the instance URL from server or gateway configuration + // because the payload is attacker-controllable. For GitLab, // AnnotationSourceRepo holds the full project path. AnnotationSourceBaseURL = "kelos.dev/source-base-url" @@ -312,7 +313,7 @@ func (tr *TaskReporter) reportViaComment(ctx context.Context, task *kelos.Task) body = FormatFailedComment(task.Name) } - if annotations[AnnotationGitHubCommentMode] == string(kelos.GitHubCommentModeSticky) { + if annotations[AnnotationGitHubCommentMode] == string(kelos.CommentModeSticky) { marker, err := stickyCommentMarker(task) if err != nil { return err diff --git a/internal/reporting/watcher_test.go b/internal/reporting/watcher_test.go index c1d12c33b..22ed68f6a 100644 --- a/internal/reporting/watcher_test.go +++ b/internal/reporting/watcher_test.go @@ -224,7 +224,7 @@ func TestReportTaskStatus_StickyCommentReusedAcrossTasks(t *testing.T) { annotations := func() map[string]string { return map[string]string{ AnnotationGitHubReporting: "enabled", - AnnotationGitHubCommentMode: string(kelos.GitHubCommentModeSticky), + AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), AnnotationSourceNumber: "42", AnnotationSourceKind: "issue", } @@ -274,7 +274,7 @@ func TestReportTaskStatus_StickyCommentsScopedByTaskSpawner(t *testing.T) { annotations := func() map[string]string { return map[string]string{ AnnotationGitHubReporting: "enabled", - AnnotationGitHubCommentMode: string(kelos.GitHubCommentModeSticky), + AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), AnnotationSourceNumber: "42", AnnotationSourceKind: "issue", } diff --git a/internal/source/comment_policy.go b/internal/source/comment_policy.go new file mode 100644 index 000000000..d5b0a2b7e --- /dev/null +++ b/internal/source/comment_policy.go @@ -0,0 +1,109 @@ +package source + +import "time" + +// commentCommands is the provider-neutral shape of a comment policy: the +// trigger command that admits an item and the exclude commands that block it. +type commentCommands struct { + Trigger string + Excludes []string +} + +func (c commentCommands) enabled() bool { + return c.Trigger != "" || len(c.Excludes) > 0 +} + +// bodyMatch records which commands the item body itself carries from an +// authorized author. A body trigger admits the item but carries no time. +type bodyMatch struct { + trigger bool + exclude bool +} + +// commentMatch records the most recent authorized command found in a comment +// thread. Comments without a parseable creation time fall back to list +// position, and only win when no timed match exists. +type commentMatch struct { + found bool + hasTime bool + time time.Time + index int +} + +func newCommentMatch() commentMatch { + return commentMatch{index: -1} +} + +// record folds the comment at position i, created at the given RFC3339 time, +// into the match, keeping the most recent one. +func (m *commentMatch) record(i int, createdAt string) { + m.found = true + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + if !m.hasTime { + m.index = i + } + return + } + if !m.hasTime || t.After(m.time) || (t.Equal(m.time) && i > m.index) { + m.hasTime = true + m.time = t + m.index = i + } +} + +// compare orders two matches by recency: creation time when both carry one, +// otherwise position in the comment list. +func (m commentMatch) compare(other commentMatch) int { + switch { + case m.found && other.found: + if m.hasTime && other.hasTime { + switch { + case m.time.After(other.time): + return 1 + case other.time.After(m.time): + return -1 + } + } + switch { + case m.index > other.index: + return 1 + case other.index > m.index: + return -1 + } + return 0 + case m.found: + return 1 + case other.found: + return -1 + } + return 0 +} + +// decideCommentPolicy applies the trigger/exclude precedence once body and +// comment matches are known: with only a trigger, any match admits; with only +// excludes, any match blocks; with both, the most recent comment command wins +// and the body breaks ties, exclude first. The returned time is the trigger +// comment's creation time, or zero when the trigger came from the body. +func decideCommentPolicy(cmds commentCommands, body bodyMatch, triggerMatch, excludeMatch commentMatch) (bool, time.Time) { + switch { + case len(cmds.Excludes) == 0: + if triggerMatch.found { + return true, triggerMatch.time + } + return body.trigger, time.Time{} + case cmds.Trigger == "": + return !excludeMatch.found && !body.exclude, time.Time{} + } + + switch triggerMatch.compare(excludeMatch) { + case 1: + return true, triggerMatch.time + case -1: + return false, time.Time{} + } + if body.exclude { + return false, time.Time{} + } + return body.trigger, time.Time{} +} diff --git a/internal/source/github_comment_policy.go b/internal/source/github_comment_policy.go index e4ff68e87..3d778a9bd 100644 --- a/internal/source/github_comment_policy.go +++ b/internal/source/github_comment_policy.go @@ -27,6 +27,10 @@ type githubCommentPolicy struct { MinimumPermission string } +func (p githubCommentPolicy) commands() commentCommands { + return commentCommands{Trigger: p.TriggerComment, Excludes: p.ExcludeComments} +} + type githubTeamRef struct { Org string Slug string @@ -36,13 +40,6 @@ type githubAuthorizationDecision struct { authorized bool } -type githubCommentMatch struct { - found bool - hasTime bool - time time.Time - index int -} - type githubCommentAuthorizer struct { owner string repo string @@ -258,73 +255,45 @@ func (a *githubCommentAuthorizer) getJSON(ctx context.Context, path string, out } func evaluateGitHubCommentPolicy(ctx context.Context, body string, bodyActor githubUser, comments []githubComment, policy githubCommentPolicy, authorizer *githubCommentAuthorizer) (bool, time.Time, error) { - if policy.TriggerComment == "" && len(policy.ExcludeComments) == 0 { + cmds := policy.commands() + if !cmds.enabled() { return true, time.Time{}, nil } - bodyHasTrigger := policy.TriggerComment != "" && containsCommand(body, policy.TriggerComment) - bodyHasExclude := len(policy.ExcludeComments) > 0 && containsAnyCommand(body, policy.ExcludeComments) - bodyMatchesTrigger := false - bodyMatchesExclude := false + bodyHasTrigger := cmds.Trigger != "" && containsCommand(body, cmds.Trigger) + bodyHasExclude := len(cmds.Excludes) > 0 && containsAnyCommand(body, cmds.Excludes) + var bodyMatches bodyMatch if bodyHasTrigger || bodyHasExclude { authorized, err := authorizer.isAuthorized(ctx, bodyActor) if err != nil { return false, time.Time{}, err } if authorized { - bodyMatchesTrigger = bodyHasTrigger - bodyMatchesExclude = bodyHasExclude + bodyMatches = bodyMatch{trigger: bodyHasTrigger, exclude: bodyHasExclude} } } - var triggerMatch githubCommentMatch + triggerMatch, excludeMatch := newCommentMatch(), newCommentMatch() var err error - if policy.TriggerComment != "" { - triggerMatch, err = latestAuthorizedCommentMatch(ctx, comments, []string{policy.TriggerComment}, authorizer) + if cmds.Trigger != "" { + triggerMatch, err = latestAuthorizedCommentMatch(ctx, comments, []string{cmds.Trigger}, authorizer) if err != nil { return false, time.Time{}, err } } - - var excludeMatch githubCommentMatch - if len(policy.ExcludeComments) > 0 { - excludeMatch, err = latestAuthorizedCommentMatch(ctx, comments, policy.ExcludeComments, authorizer) + if len(cmds.Excludes) > 0 { + excludeMatch, err = latestAuthorizedCommentMatch(ctx, comments, cmds.Excludes, authorizer) if err != nil { return false, time.Time{}, err } } - if policy.TriggerComment != "" && len(policy.ExcludeComments) == 0 { - if triggerMatch.found { - return true, triggerMatch.time, nil - } - return bodyMatchesTrigger, time.Time{}, nil - } - - if len(policy.ExcludeComments) > 0 && policy.TriggerComment == "" { - if excludeMatch.found || bodyMatchesExclude { - return false, time.Time{}, nil - } - return true, time.Time{}, nil - } - - switch compareGitHubCommentMatches(triggerMatch, excludeMatch) { - case 1: - return true, triggerMatch.time, nil - case -1: - return false, time.Time{}, nil - } - if bodyMatchesExclude { - return false, time.Time{}, nil - } - if bodyMatchesTrigger { - return true, time.Time{}, nil - } - return false, time.Time{}, nil + allowed, triggerTime := decideCommentPolicy(cmds, bodyMatches, triggerMatch, excludeMatch) + return allowed, triggerTime, nil } -func latestAuthorizedCommentMatch(ctx context.Context, comments []githubComment, commands []string, authorizer *githubCommentAuthorizer) (githubCommentMatch, error) { - match := githubCommentMatch{index: -1} +func latestAuthorizedCommentMatch(ctx context.Context, comments []githubComment, commands []string, authorizer *githubCommentAuthorizer) (commentMatch, error) { + match := newCommentMatch() for i, comment := range comments { if !containsAnyCommand(comment.Body, commands) { @@ -333,58 +302,18 @@ func latestAuthorizedCommentMatch(ctx context.Context, comments []githubComment, authorized, err := authorizer.isAuthorized(ctx, comment.User) if err != nil { - return githubCommentMatch{}, err + return commentMatch{}, err } if !authorized { continue } - match.found = true - createdAt, err := time.Parse(time.RFC3339, comment.CreatedAt) - if err != nil { - if !match.hasTime { - match.index = i - } - continue - } - if !match.hasTime || createdAt.After(match.time) || (createdAt.Equal(match.time) && i > match.index) { - match.hasTime = true - match.time = createdAt - match.index = i - } + match.record(i, comment.CreatedAt) } return match, nil } -func compareGitHubCommentMatches(left, right githubCommentMatch) int { - switch { - case left.found && right.found: - if left.hasTime && right.hasTime { - switch { - case left.time.After(right.time): - return 1 - case right.time.After(left.time): - return -1 - } - } - switch { - case left.index > right.index: - return 1 - case right.index > left.index: - return -1 - default: - return 0 - } - case left.found: - return 1 - case right.found: - return -1 - default: - return 0 - } -} - func normalizeGitHubLogin(login string) string { return strings.ToLower(strings.TrimSpace(login)) } diff --git a/internal/source/gitlab.go b/internal/source/gitlab.go index b8bfc57fd..e4e4dab6d 100644 --- a/internal/source/gitlab.go +++ b/internal/source/gitlab.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -21,6 +22,12 @@ const ( pipelineStatusAny = "any" ) +// gitlabResourceByType maps the API-facing type names to GitLab REST resources. +var gitlabResourceByType = map[string]string{ + "issues": gitlabResourceIssues, + "mergeRequests": gitlabResourceMergeRequests, +} + // GitLabSource discovers issues and merge requests from a GitLab project. type GitLabSource struct { // BaseURL is the GitLab instance URL (e.g. "https://gitlab.example.com"). @@ -44,8 +51,9 @@ type GitLabSource struct { } // gitlabItem is the shared subset of the GitLab issue and merge request -// representations. SourceBranch, SHA, and HeadPipeline are only populated for -// merge requests; HeadPipeline only on the single merge request endpoint. +// representations. SourceBranch, SHA, DetailedMergeStatus, and HeadPipeline +// are only populated for merge requests; HeadPipeline only on the single +// merge request endpoint. type gitlabItem struct { IID int `json:"iid"` Title string `json:"title"` @@ -56,6 +64,9 @@ type gitlabItem struct { SourceBranch string `json:"source_branch"` SHA string `json:"sha"` HeadPipeline *gitlabPipeline `json:"head_pipeline"` + // DetailedMergeStatus is "requested_changes" while any reviewer has + // requested changes. + DetailedMergeStatus string `json:"detailed_merge_status"` } type gitlabPipeline struct { @@ -92,10 +103,6 @@ type gitlabApprovals struct { Approved bool `json:"approved"` } -type gitlabReviewer struct { - State string `json:"state"` -} - func (s *GitLabSource) baseURL() string { if s.BaseURL != "" { return strings.TrimRight(s.BaseURL, "/") @@ -223,7 +230,7 @@ func (s *GitLabSource) enrichMergeRequest(ctx context.Context, iid int, item *Wo } if desired := s.resolvedReviewState(); desired != reviewStateAny { - reviewState, err := s.fetchReviewState(ctx, iid) + reviewState, err := s.fetchReviewState(ctx, iid, detail.DetailedMergeStatus) if err != nil { return false, time.Time{}, err } @@ -245,25 +252,17 @@ func (p *gitlabPipeline) finishedTime() time.Time { return time.Time{} } -// fetchReviewState aggregates GitLab approvals and reviewer states into the +// fetchReviewState maps GitLab's detailed merge status and approvals onto the // GitHub-style review states: "changes_requested" when any reviewer requested // changes, "approved" when the merge request has the required approvals, and // "" otherwise. -func (s *GitLabSource) fetchReviewState(ctx context.Context, iid int) (string, error) { - mrURL := fmt.Sprintf("%s/%s/%d", s.projectURL(), gitlabResourceMergeRequests, iid) - - var reviewers []gitlabReviewer - if err := s.getJSON(ctx, mrURL+"/reviewers", nil, &reviewers); err != nil { - return "", fmt.Errorf("fetching reviewers for merge request !%d: %w", iid, err) - } - for _, r := range reviewers { - if r.State == "requested_changes" { - return reviewStateChangesRequested, nil - } +func (s *GitLabSource) fetchReviewState(ctx context.Context, iid int, detailedMergeStatus string) (string, error) { + if detailedMergeStatus == "requested_changes" { + return reviewStateChangesRequested, nil } var approvals gitlabApprovals - if err := s.getJSON(ctx, mrURL+"/approvals", nil, &approvals); err != nil { + if err := s.getJSON(ctx, fmt.Sprintf("%s/%s/%d/approvals", s.projectURL(), gitlabResourceMergeRequests, iid), nil, &approvals); err != nil { return "", fmt.Errorf("fetching approvals for merge request !%d: %w", iid, err) } if approvals.Approved { @@ -286,19 +285,21 @@ func (s *GitLabSource) resolvedPipelineStatus() string { return strings.ToLower(s.PipelineStatus) } -// resolvedResources maps the API-facing type names to GitLab REST resources. +// resolvedResources returns the GitLab REST resources to poll, in the order +// first listed in Types and without duplicates. func (s *GitLabSource) resolvedResources() []string { if len(s.Types) == 0 { return []string{gitlabResourceIssues} } var resources []string + seen := map[string]bool{} for _, t := range s.Types { - switch t { - case "issues": - resources = append(resources, gitlabResourceIssues) - case "mergeRequests": - resources = append(resources, gitlabResourceMergeRequests) + resource := gitlabResourceByType[t] + if resource == "" || seen[resource] { + continue } + seen[resource] = true + resources = append(resources, resource) } return resources } @@ -327,7 +328,9 @@ func concatGitLabDiffNotes(notes []gitlabNote) string { if n.Position != nil { location := n.Position.NewPath line := n.Position.NewLine - if location == "" { + // Notes on deleted lines carry new_path but no new_line; the + // old side is the only location that exists. + if location == "" || line <= 0 { location = n.Position.OldPath line = n.Position.OldLine } @@ -371,10 +374,13 @@ func (s *GitLabSource) fetchAllItems(ctx context.Context, resource string) ([]gi return all, err } +// fetchNotes returns an item's notes oldest first. They are requested newest +// first so that the maxPages cap truncates the oldest notes, never a recent +// trigger command. func (s *GitLabSource) fetchNotes(ctx context.Context, resource string, iid int) ([]gitlabNote, error) { params := url.Values{} params.Set("per_page", "100") - params.Set("sort", "asc") + params.Set("sort", "desc") params.Set("order_by", "created_at") var all []gitlabNote @@ -386,6 +392,7 @@ func (s *GitLabSource) fetchNotes(ctx context.Context, resource string, iid int) all = append(all, page...) return len(page), nil }) + slices.Reverse(all) return all, err } diff --git a/internal/source/gitlab_comment_policy.go b/internal/source/gitlab_comment_policy.go index 15b8911ea..70ec91e6f 100644 --- a/internal/source/gitlab_comment_policy.go +++ b/internal/source/gitlab_comment_policy.go @@ -15,19 +15,29 @@ type gitlabCommentPolicy struct { AllowedUsers []string } +func (p gitlabCommentPolicy) commands() commentCommands { + return commentCommands{Trigger: p.TriggerComment, Excludes: p.ExcludeComments} +} + func (p gitlabCommentPolicy) enabled() bool { - return p.TriggerComment != "" || len(p.ExcludeComments) > 0 + return p.commands().enabled() } // gitlabCommentAuthorizer decides whether a GitLab user may issue commands. // Authorization is a username allow-list; an empty list authorizes everyone. -// Access-level checks against /projects/:id/members/all would slot in here. +// A configured list whose entries are all blank fails closed rather than +// silently authorizing everyone. Access-level checks against +// /projects/:id/members/all would slot in here. type gitlabCommentAuthorizer struct { + restricted bool allowedUsers map[string]struct{} } func newGitLabCommentAuthorizer(policy gitlabCommentPolicy) *gitlabCommentAuthorizer { - a := &gitlabCommentAuthorizer{allowedUsers: make(map[string]struct{}, len(policy.AllowedUsers))} + a := &gitlabCommentAuthorizer{ + restricted: len(policy.AllowedUsers) > 0, + allowedUsers: make(map[string]struct{}, len(policy.AllowedUsers)), + } for _, user := range policy.AllowedUsers { if normalized := normalizeGitLabUsername(user); normalized != "" { a.allowedUsers[normalized] = struct{}{} @@ -37,20 +47,13 @@ func newGitLabCommentAuthorizer(policy gitlabCommentPolicy) *gitlabCommentAuthor } func (a *gitlabCommentAuthorizer) isAuthorized(username string) bool { - if len(a.allowedUsers) == 0 { + if !a.restricted { return true } _, ok := a.allowedUsers[normalizeGitLabUsername(username)] return ok } -type gitlabCommentMatch struct { - found bool - hasTime bool - time time.Time - index int -} - // evaluateGitLabCommentPolicy reports whether the item passes the policy and, // when a trigger note matched, the note's creation time so re-triggers on a // finished item can be detected. A trigger in the description counts but @@ -60,91 +63,35 @@ func evaluateGitLabCommentPolicy(description, author string, notes []gitlabNote, return true, time.Time{} } - authorAuthorized := authorizer.isAuthorized(author) - bodyMatchesTrigger := authorAuthorized && policy.TriggerComment != "" && containsCommand(description, policy.TriggerComment) - bodyMatchesExclude := authorAuthorized && len(policy.ExcludeComments) > 0 && containsAnyCommand(description, policy.ExcludeComments) - - var triggerMatch, excludeMatch gitlabCommentMatch - if policy.TriggerComment != "" { - triggerMatch = latestAuthorizedGitLabNote(notes, []string{policy.TriggerComment}, authorizer) - } - if len(policy.ExcludeComments) > 0 { - excludeMatch = latestAuthorizedGitLabNote(notes, policy.ExcludeComments, authorizer) - } - - switch { - case len(policy.ExcludeComments) == 0: - if triggerMatch.found { - return true, triggerMatch.time - } - return bodyMatchesTrigger, time.Time{} - case policy.TriggerComment == "": - return !excludeMatch.found && !bodyMatchesExclude, time.Time{} + cmds := policy.commands() + var body bodyMatch + if authorizer.isAuthorized(author) { + body.trigger = cmds.Trigger != "" && containsCommand(description, cmds.Trigger) + body.exclude = len(cmds.Excludes) > 0 && containsAnyCommand(description, cmds.Excludes) } - switch compareGitLabCommentMatches(triggerMatch, excludeMatch) { - case 1: - return true, triggerMatch.time - case -1: - return false, time.Time{} + triggerMatch, excludeMatch := newCommentMatch(), newCommentMatch() + if cmds.Trigger != "" { + triggerMatch = latestAuthorizedGitLabNote(notes, []string{cmds.Trigger}, authorizer) } - if bodyMatchesExclude { - return false, time.Time{} + if len(cmds.Excludes) > 0 { + excludeMatch = latestAuthorizedGitLabNote(notes, cmds.Excludes, authorizer) } - return bodyMatchesTrigger, time.Time{} + + return decideCommentPolicy(cmds, body, triggerMatch, excludeMatch) } -func latestAuthorizedGitLabNote(notes []gitlabNote, commands []string, authorizer *gitlabCommentAuthorizer) gitlabCommentMatch { - match := gitlabCommentMatch{index: -1} +func latestAuthorizedGitLabNote(notes []gitlabNote, commands []string, authorizer *gitlabCommentAuthorizer) commentMatch { + match := newCommentMatch() for i, note := range notes { if note.System || !containsAnyCommand(note.Body, commands) || !authorizer.isAuthorized(note.Author.Username) { continue } - match.found = true - createdAt, err := time.Parse(time.RFC3339, note.CreatedAt) - if err != nil { - if !match.hasTime { - match.index = i - } - continue - } - if !match.hasTime || createdAt.After(match.time) || (createdAt.Equal(match.time) && i > match.index) { - match.hasTime = true - match.time = createdAt - match.index = i - } + match.record(i, note.CreatedAt) } return match } -// compareGitLabCommentMatches orders two matches by recency: creation time -// when both carry one, otherwise position in the notes list. -func compareGitLabCommentMatches(left, right gitlabCommentMatch) int { - switch { - case left.found && right.found: - if left.hasTime && right.hasTime { - switch { - case left.time.After(right.time): - return 1 - case right.time.After(left.time): - return -1 - } - } - switch { - case left.index > right.index: - return 1 - case right.index > left.index: - return -1 - } - return 0 - case left.found: - return 1 - case right.found: - return -1 - } - return 0 -} - func normalizeGitLabUsername(username string) string { return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) } diff --git a/internal/source/gitlab_comment_policy_test.go b/internal/source/gitlab_comment_policy_test.go index de3a08282..75415ebd4 100644 --- a/internal/source/gitlab_comment_policy_test.go +++ b/internal/source/gitlab_comment_policy_test.go @@ -68,6 +68,12 @@ func TestEvaluateGitLabCommentPolicy(t *testing.T) { notes: []gitlabNote{gitlabTestNote(trigger, "mallory", t1)}, wantAllowed: false, }, + { + name: "allowlist of blank entries fails closed", + policy: gitlabCommentPolicy{TriggerComment: trigger, AllowedUsers: []string{" ", "@"}}, + notes: []gitlabNote{gitlabTestNote(trigger, "alice", t1)}, + wantAllowed: false, + }, { name: "unauthorized description author is ignored", policy: gitlabCommentPolicy{TriggerComment: trigger, AllowedUsers: []string{"alice"}}, diff --git a/internal/source/gitlab_test.go b/internal/source/gitlab_test.go index a601598f8..ba60c0a18 100644 --- a/internal/source/gitlab_test.go +++ b/internal/source/gitlab_test.go @@ -5,29 +5,50 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "strconv" "strings" + "sync" "testing" "time" ) // gitlabFixture holds the data served by newGitLabTestServer for project -// "group/sub/repo". Merge request details (including head_pipeline) come from -// mrs; notes, reviewers, and approvals are keyed by "/". +// "group/sub/repo". Merge request details (including head_pipeline and +// detailed_merge_status) come from mrs; notes are keyed by +// "/" and approvals by merge request iid. type gitlabFixture struct { issues []gitlabItem mrs []gitlabItem notes map[string][]gitlabNote - reviewers map[int][]gitlabReviewer approvals map[int]gitlabApprovals } -func newGitLabTestServer(t *testing.T, fx gitlabFixture, seen *[]*http.Request) *httptest.Server { +// requestLog records requests from the server goroutine for inspection after +// Discover returns. +type requestLog struct { + mu sync.Mutex + reqs []*http.Request +} + +func (l *requestLog) add(r *http.Request) { + l.mu.Lock() + defer l.mu.Unlock() + l.reqs = append(l.reqs, r) +} + +func (l *requestLog) all() []*http.Request { + l.mu.Lock() + defer l.mu.Unlock() + return append([]*http.Request(nil), l.reqs...) +} + +func newGitLabTestServer(t *testing.T, fx gitlabFixture, seen *requestLog) *httptest.Server { t.Helper() const prefix = "/api/v4/projects/group%2Fsub%2Frepo/" return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if seen != nil { - *seen = append(*seen, r) + seen.add(r) } path := r.URL.EscapedPath() if !strings.HasPrefix(path, prefix) { @@ -52,10 +73,12 @@ func newGitLabTestServer(t *testing.T, fx gitlabFixture, seen *[]*http.Request) } w.WriteHeader(http.StatusNotFound) case len(parts) == 3 && parts[2] == "notes": - json.NewEncoder(w).Encode(fx.notes[parts[0]+"/"+parts[1]]) - case len(parts) == 3 && parts[2] == "reviewers": - iid, _ := strconv.Atoi(parts[1]) - json.NewEncoder(w).Encode(fx.reviewers[iid]) + // Fixtures list notes chronologically; honour sort=desc like GitLab. + notes := slices.Clone(fx.notes[parts[0]+"/"+parts[1]]) + if r.URL.Query().Get("sort") == "desc" { + slices.Reverse(notes) + } + json.NewEncoder(w).Encode(notes) case len(parts) == 3 && parts[2] == "approvals": iid, _ := strconv.Atoi(parts[1]) json.NewEncoder(w).Encode(fx.approvals[iid]) @@ -80,8 +103,8 @@ func TestGitLabDiscoverIssues(t *testing.T) { }, }, } - var seen []*http.Request - server := newGitLabTestServer(t, fx, &seen) + var log requestLog + server := newGitLabTestServer(t, fx, &log) defer server.Close() s := &GitLabSource{ @@ -110,6 +133,7 @@ func TestGitLabDiscoverIssues(t *testing.T) { t.Errorf("expected system notes dropped from comments, got %q", got.Comments) } + seen := log.all() list := seen[0] if list.Header.Get("PRIVATE-TOKEN") != "glpat-secret" { t.Errorf("expected PRIVATE-TOKEN header, got %q", list.Header.Get("PRIVATE-TOKEN")) @@ -119,7 +143,12 @@ func TestGitLabDiscoverIssues(t *testing.T) { t.Errorf("unexpected list query: %v", q) } if len(seen) != 2 || !strings.HasSuffix(seen[1].URL.EscapedPath(), "/issues/1/notes") { - t.Errorf("expected exactly list + notes requests for issues, got %d requests", len(seen)) + t.Fatalf("expected exactly list + notes requests for issues, got %d requests", len(seen)) + } + // Newest-first paging keeps recent trigger notes inside the page cap; + // Comments above proves the result is still chronological. + if q := seen[1].URL.Query(); q.Get("sort") != "desc" || q.Get("order_by") != "created_at" { + t.Errorf("expected notes requested newest first, got %v", q) } } @@ -136,11 +165,12 @@ func TestGitLabDiscoverMergeRequests(t *testing.T) { {Body: "looks good overall", Author: gitlabUser{Username: "bob"}}, {Body: "rename this", Type: "DiffNote", Author: gitlabUser{Username: "bob"}, Position: &gitlabNotePosition{NewPath: "main.go", NewLine: 12}}, {Body: "deleted line comment", Type: "DiffNote", Author: gitlabUser{Username: "bob"}, Position: &gitlabNotePosition{OldPath: "old.go", OldLine: 3}}, + {Body: "removed in place", Type: "DiffNote", Author: gitlabUser{Username: "bob"}, Position: &gitlabNotePosition{NewPath: "main.go", OldPath: "main.go", OldLine: 8}}, }, }, } - var seen []*http.Request - server := newGitLabTestServer(t, fx, &seen) + var log requestLog + server := newGitLabTestServer(t, fx, &log) defer server.Close() s := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"mergeRequests"}} @@ -165,19 +195,44 @@ func TestGitLabDiscoverMergeRequests(t *testing.T) { if got.Comments != "looks good overall" { t.Errorf("expected diff notes excluded from comments, got %q", got.Comments) } - if got.ReviewComments != "main.go:12\nrename this\n---\nold.go:3\ndeleted line comment" { + if got.ReviewComments != "main.go:12\nrename this\n---\nold.go:3\ndeleted line comment\n---\nmain.go:8\nremoved in place" { t.Errorf("unexpected review comments %q", got.ReviewComments) } if got.ReviewState != "" || !got.TriggerTime.IsZero() { t.Errorf("expected no review state or trigger time without gates, got %+v", got) } - for _, r := range seen { - if strings.HasSuffix(r.URL.Path, "/approvals") || strings.HasSuffix(r.URL.Path, "/reviewers") { + for _, r := range log.all() { + if strings.HasSuffix(r.URL.Path, "/approvals") { t.Errorf("review endpoints must not be fetched without a reviewState gate: %s", r.URL.Path) } } } +func TestGitLabDiscoverDuplicateTypesPollOnce(t *testing.T) { + fx := gitlabFixture{issues: []gitlabItem{{IID: 1, Title: "Issue 1"}}} + var log requestLog + server := newGitLabTestServer(t, fx, &log) + defer server.Close() + + s := &GitLabSource{BaseURL: server.URL, Project: "group/sub/repo", Types: []string{"issues", "issues"}} + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected duplicate types to yield one item, got %+v", items) + } + lists := 0 + for _, r := range log.all() { + if strings.HasSuffix(r.URL.EscapedPath(), "/issues") { + lists++ + } + } + if lists != 1 { + t.Errorf("expected one issues list request, got %d", lists) + } +} + func TestGitLabDiscoverPipelineStatusGate(t *testing.T) { finished := "2026-03-01T10:00:00Z" fx := gitlabFixture{ @@ -207,12 +262,9 @@ func TestGitLabDiscoverPipelineStatusGate(t *testing.T) { func TestGitLabDiscoverReviewStateGate(t *testing.T) { fx := gitlabFixture{ mrs: []gitlabItem{ - {IID: 1, Title: "approved"}, - {IID: 2, Title: "changes requested"}, - {IID: 3, Title: "unreviewed"}, - }, - reviewers: map[int][]gitlabReviewer{ - 2: {{State: "reviewed"}, {State: "requested_changes"}}, + {IID: 1, Title: "approved", DetailedMergeStatus: "mergeable"}, + {IID: 2, Title: "changes requested", DetailedMergeStatus: "requested_changes"}, + {IID: 3, Title: "unreviewed", DetailedMergeStatus: "not_approved"}, }, approvals: map[int]gitlabApprovals{ 1: {Approved: true}, @@ -331,6 +383,7 @@ func TestGitLabDiscoverPipelineAndCommentTriggerTimes(t *testing.T) { } func TestGitLabDiscoverPagination(t *testing.T) { + var mu sync.Mutex var pages []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/notes") { @@ -338,7 +391,9 @@ func TestGitLabDiscoverPagination(t *testing.T) { return } page := r.URL.Query().Get("page") + mu.Lock() pages = append(pages, page) + mu.Unlock() switch page { case "1": w.Header().Set("X-Next-Page", "2") @@ -360,6 +415,8 @@ func TestGitLabDiscoverPagination(t *testing.T) { if len(items) != 2 { t.Errorf("expected 2 items across pages, got %d", len(items)) } + mu.Lock() + defer mu.Unlock() if len(pages) != 2 || pages[0] != "1" || pages[1] != "2" { t.Errorf("expected pages [1 2], got %v", pages) } diff --git a/internal/source/prompt_test.go b/internal/source/prompt_test.go index aabd6baeb..84199f78e 100644 --- a/internal/source/prompt_test.go +++ b/internal/source/prompt_test.go @@ -131,18 +131,32 @@ func TestRenderPromptAllVariables(t *testing.T) { Branch: "kelos-task-99", ReviewState: "changes_requested", ReviewComments: "foo.go:10\nHandle the error", + PipelineStatus: "failed", + PipelineURL: "https://gitlab.example.com/g/r/-/pipelines/1", } - tmpl := "{{.ID}} {{.Number}} {{.Title}} {{.Body}} {{.URL}} {{.Labels}} {{.Comments}} {{.Kind}} {{.Branch}} {{.ReviewState}} {{.ReviewComments}}" + tmpl := "{{.ID}} {{.Number}} {{.Title}} {{.Body}} {{.URL}} {{.Labels}} {{.Comments}} {{.Kind}} {{.Branch}} {{.ReviewState}} {{.ReviewComments}} {{.PipelineStatus}} {{.PipelineURL}}" result, err := RenderPrompt(tmpl, item) if err != nil { t.Fatalf("unexpected error: %v", err) } - expected := "99 99 T B U a, b C PR kelos-task-99 changes_requested foo.go:10\nHandle the error" + expected := "99 99 T B U a, b C PR kelos-task-99 changes_requested foo.go:10\nHandle the error failed https://gitlab.example.com/g/r/-/pipelines/1" if result != expected { t.Errorf("expected %q, got %q", expected, result) } + + // The map used by webhook-style rendering must expose the same variables + // as the struct path. + vars := WorkItemToTemplateVars(item) + for _, key := range []string{"ID", "Number", "Title", "Body", "URL", "Labels", "Comments", "Kind", "Branch", "ReviewState", "ReviewComments", "PipelineStatus", "PipelineURL", "Time", "Schedule"} { + if _, ok := vars[key]; !ok { + t.Errorf("WorkItemToTemplateVars missing %q", key) + } + } + if vars["PipelineStatus"] != "failed" || vars["PipelineURL"] != item.PipelineURL || vars["Labels"] != "a, b" { + t.Errorf("unexpected template vars: %+v", vars) + } } func TestRenderPromptInvalidTemplate(t *testing.T) { diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 8c90e4c6a..0007a3d52 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -671,6 +671,18 @@ func TestSourceTypeExtraction(t *testing.T) { When: kelos.When{GitHubIssues: &kelos.GitHubIssues{}}, }, }, + { + ObjectMeta: metav1.ObjectMeta{Name: "s10", Namespace: "ns"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLab: &kelos.GitLab{}}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "s11", Namespace: "ns"}, + Spec: kelos.TaskSpawnerSpec{ + When: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{}}, + }, + }, } objs := make([]runtime.Object, 0) @@ -691,7 +703,7 @@ func TestSourceTypeExtraction(t *testing.T) { } sort.Strings(report.Features.SourceTypes) - expected := []string{"cron", "github", "jira"} + expected := []string{"cron", "github", "gitlab", "jira"} if len(report.Features.SourceTypes) != len(expected) { t.Fatalf("SourceTypes length = %d, want %d", len(report.Features.SourceTypes), len(expected)) } @@ -710,6 +722,8 @@ func TestSourceTypeExtraction(t *testing.T) { "cron": 1, "jira": 1, "slack": 1, + "gitlab": 1, + "gitlab_webhook": 1, } for source, count := range expectedBySource { if report.TaskSpawners.BySource[source] != count { diff --git a/internal/webhook/gitlab_filter.go b/internal/webhook/gitlab_filter.go index a01322137..097eadc15 100644 --- a/internal/webhook/gitlab_filter.go +++ b/internal/webhook/gitlab_filter.go @@ -335,14 +335,17 @@ func matchesGitLabFilter(filter *kelos.GitLabWebhookFilter, eventData *GitLabEve return true, nil } -// gitlabInstanceURL reduces a project web URL to the instance URL -// (scheme and host), which is the API base for that GitLab. -func gitlabInstanceURL(projectURL string) string { +// gitlabInstanceURL reduces a project web URL to the instance URL by +// removing the project path, so a GitLab served under a relative URL root +// (https://host/gitlab/group/repo) keeps its prefix. +func gitlabInstanceURL(projectURL, project string) string { parsed, err := url.Parse(projectURL) if err != nil || parsed.Host == "" { return "" } - return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String() + parsed.Path = strings.TrimSuffix(strings.TrimSuffix(parsed.Path, "/"), "/"+strings.Trim(project, "/")) + parsed.RawQuery, parsed.Fragment = "", "" + return strings.TrimSuffix(parsed.String(), "/") } func containsFold(list []string, value string) bool { diff --git a/internal/webhook/gitlab_filter_test.go b/internal/webhook/gitlab_filter_test.go index dd0a0c2bd..86d12754d 100644 --- a/internal/webhook/gitlab_filter_test.go +++ b/internal/webhook/gitlab_filter_test.go @@ -151,7 +151,9 @@ func TestParseGitLabWebhook_Pipeline(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if data.Event != "pipeline" || data.PipelineStatus != "failed" || data.ID != "pipeline-555" { + // A pipeline attached to a merge request shares the merge request's ID so + // pipeline and note deliveries for one MR dedupe onto the same Task. + if data.Event != "pipeline" || data.PipelineStatus != "failed" || data.ID != "mr-7" { t.Errorf("unexpected pipeline identity: %+v", data) } if data.PipelineURL != "https://gitlab.example.com/group/sub/repo/-/pipelines/555" { @@ -237,15 +239,17 @@ func TestMatchesGitLabEvent(t *testing.T) { } func TestGitLabInstanceURL(t *testing.T) { - tests := map[string]string{ - "https://gitlab.example.com/group/sub/repo": "https://gitlab.example.com", - "http://gitlab-webservice.gitlab.svc:8181/grp/repo": "http://gitlab-webservice.gitlab.svc:8181", - "": "", - "not a url": "", - } - for in, want := range tests { - if got := gitlabInstanceURL(in); got != want { - t.Errorf("gitlabInstanceURL(%q) = %q, want %q", in, got, want) + tests := []struct{ projectURL, project, want string }{ + {"https://gitlab.example.com/group/sub/repo", "group/sub/repo", "https://gitlab.example.com"}, + {"http://gitlab-webservice.gitlab.svc:8181/grp/repo", "grp/repo", "http://gitlab-webservice.gitlab.svc:8181"}, + {"https://example.com/gitlab/group/repo", "group/repo", "https://example.com/gitlab"}, + {"https://example.com/gitlab/group/repo/", "group/repo", "https://example.com/gitlab"}, + {"", "group/repo", ""}, + {"not a url", "group/repo", ""}, + } + for _, tt := range tests { + if got := gitlabInstanceURL(tt.projectURL, tt.project); got != tt.want { + t.Errorf("gitlabInstanceURL(%q, %q) = %q, want %q", tt.projectURL, tt.project, got, tt.want) } } } diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 6c7e0afef..49cc07b29 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -781,7 +781,7 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *kelos.TaskSpaw } if commentReportingEnabled { task.Annotations[reporting.AnnotationGitHubReporting] = "enabled" - commentMode := kelos.GitHubCommentModePerTask + commentMode := kelos.CommentModePerTask if rep.Comments != nil && rep.Comments.Mode != "" { commentMode = rep.Comments.Mode } @@ -813,12 +813,12 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *kelos.TaskSpaw task.Annotations[reporting.AnnotationSourceKind] = kind task.Annotations[reporting.AnnotationSourceNumber] = strconv.Itoa(parsed.GitLab.Number) task.Annotations[reporting.AnnotationSourceRepo] = parsed.GitLab.Project - task.Annotations[reporting.AnnotationSourceBaseURL] = gitlabInstanceURL(parsed.GitLab.ProjectURL) + task.Annotations[reporting.AnnotationSourceBaseURL] = gitlabInstanceURL(parsed.GitLab.ProjectURL, parsed.GitLab.Project) if h.gatewayName != "" { task.Annotations[reporting.AnnotationWebhookGateway] = h.gatewayName } task.Annotations[reporting.AnnotationGitHubReporting] = "enabled" - commentMode := kelos.GitHubCommentModePerTask + commentMode := kelos.CommentModePerTask if mode := spawner.Spec.When.GitLabWebhook.Reporting.Comments.Mode; mode != "" { commentMode = mode } diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go index a7d233700..f8ea7711e 100644 --- a/internal/webhook/handler_test.go +++ b/internal/webhook/handler_test.go @@ -652,7 +652,7 @@ func TestServeHTTP_StampsStickyCommentReportingAnnotations(t *testing.T) { GitHubWebhook: &kelos.GitHubWebhook{ Events: []string{"issues"}, Reporting: &kelos.GitHubReporting{ - Comments: &kelos.GitHubCommentsReporting{Mode: kelos.GitHubCommentModeSticky}, + Comments: &kelos.GitHubCommentsReporting{Mode: kelos.CommentModeSticky}, }, }, }, @@ -698,7 +698,7 @@ func TestServeHTTP_StampsStickyCommentReportingAnnotations(t *testing.T) { if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) } - if task.Annotations[reporting.AnnotationGitHubCommentMode] != string(kelos.GitHubCommentModeSticky) { + if task.Annotations[reporting.AnnotationGitHubCommentMode] != string(kelos.CommentModeSticky) { t.Errorf("Expected Sticky comment mode, got %q", task.Annotations[reporting.AnnotationGitHubCommentMode]) } if task.Annotations[reporting.AnnotationSourceKind] != "issue" { @@ -1489,7 +1489,7 @@ func TestGitLabServeHTTP_StampsReportingAnnotations(t *testing.T) { When: kelos.When{ GitLabWebhook: &kelos.GitLabWebhook{ Events: []string{"note"}, - Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{Mode: kelos.GitHubCommentModeSticky}}, + Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{Mode: kelos.CommentModeSticky}}, }, }, TaskTemplate: kelos.TaskTemplate{ @@ -1527,7 +1527,7 @@ func TestGitLabServeHTTP_StampsReportingAnnotations(t *testing.T) { reporting.AnnotationSourceBaseURL: "https://gitlab.example.com", reporting.AnnotationWebhookGateway: "gl-gateway", reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationGitHubCommentMode: string(kelos.GitHubCommentModeSticky), + reporting.AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), } for k, v := range want { if got[k] != v { diff --git a/test/integration/taskspawner_test.go b/test/integration/taskspawner_test.go index 034b79c7e..b8a116234 100644 --- a/test/integration/taskspawner_test.go +++ b/test/integration/taskspawner_test.go @@ -2651,7 +2651,7 @@ var _ = Describe("TaskSpawner Controller", func() { created := &kelos.TaskSpawner{} Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ts), created)).Should(Succeed()) - Expect(created.Spec.When.GitHubIssues.Reporting.Comments.Mode).To(Equal(kelos.GitHubCommentModePerTask)) + Expect(created.Spec.When.GitHubIssues.Reporting.Comments.Mode).To(Equal(kelos.CommentModePerTask)) }) It("Should reject an unsupported comment mode", func() { From eab8eda4be207a79ae3d822c1cccef3f18fc8a99 Mon Sep 17 00:00:00 2001 From: Jan Soukup Date: Wed, 2 Sep 2026 23:13:44 +0200 Subject: [PATCH 5/7] ref(traits): centralize the traits more closely for easier extendibility, more careful orchestration of providers to make them look-alike --- api/v1alpha2/taskspawner_tracker.go | 90 ++++ api/v1alpha2/taskspawner_tracker_test.go | 116 +++++ api/v1alpha2/taskspawner_types.go | 22 +- api/v1alpha2/zz_generated.deepcopy.go | 79 ++-- cmd/kelos-spawner/main.go | 94 +--- cmd/kelos-spawner/main_test.go | 14 +- cmd/kelos-spawner/reconciler.go | 22 +- internal/cli/printer.go | 11 +- internal/cli/run_from_taskspawner.go | 10 +- .../taskspawner_deployment_builder.go | 31 +- .../taskspawner_deployment_builder_test.go | 4 +- internal/controller/workspace_provider.go | 10 +- internal/conversion/taskspawner.go | 4 +- internal/conversion/taskspawner_test.go | 4 +- .../templates/sessionspawner-crd.yaml | 2 +- .../kelos-crds/templates/taskspawner-crd.yaml | 14 +- internal/manifests/install-crd.yaml | 16 +- internal/source/comment_policy.go | 84 +++- internal/source/github.go | 272 ++++-------- internal/source/github_comment_policy.go | 66 +-- internal/source/github_comment_policy_test.go | 28 +- internal/source/github_pr.go | 275 ++++-------- internal/source/github_pr_test.go | 24 +- internal/source/gitlab.go | 235 ++++------ internal/source/gitlab_comment_policy.go | 46 +- internal/source/tracker.go | 160 +++++++ internal/source/tracker_test.go | 157 +++++++ internal/webhook/gateway_handler.go | 26 +- internal/webhook/handler.go | 412 +++--------------- internal/webhook/handler_test.go | 6 +- internal/webhook/provider.go | 96 ++++ internal/webhook/provider_generic.go | 77 ++++ internal/webhook/provider_github.go | 73 ++++ internal/webhook/provider_gitlab.go | 72 +++ internal/webhook/provider_linear.go | 69 +++ internal/webhook/provider_test.go | 40 ++ test/integration/taskspawner_test.go | 4 +- 37 files changed, 1543 insertions(+), 1222 deletions(-) create mode 100644 api/v1alpha2/taskspawner_tracker.go create mode 100644 api/v1alpha2/taskspawner_tracker_test.go create mode 100644 internal/source/tracker.go create mode 100644 internal/source/tracker_test.go create mode 100644 internal/webhook/provider.go create mode 100644 internal/webhook/provider_generic.go create mode 100644 internal/webhook/provider_github.go create mode 100644 internal/webhook/provider_gitlab.go create mode 100644 internal/webhook/provider_linear.go create mode 100644 internal/webhook/provider_test.go diff --git a/api/v1alpha2/taskspawner_tracker.go b/api/v1alpha2/taskspawner_tracker.go new file mode 100644 index 000000000..17d9b0586 --- /dev/null +++ b/api/v1alpha2/taskspawner_tracker.go @@ -0,0 +1,90 @@ +package v1alpha2 + +// TrackerSource is the provider-neutral view of a code-host source on a +// TaskSpawner. Polling sources (githubIssues, githubPullRequests, gitlab) and +// webhook sources (githubWebhook, gitlabWebhook) all project onto it, so code +// that only needs the provider, the repository, or the reporting +// configuration does not branch on the concrete source. +type TrackerSource struct { + // Provider is the code host, using the Workspace provider values. + Provider string + // Webhook is true for event-driven sources and false for polling sources. + Webhook bool + // Repo scopes the source to a repository other than the Workspace's: the + // GitHub "owner/repo" override or restriction, or the GitLab project path. + // Empty means the Workspace repository. + Repo string + // PriorityLabels orders discovered items for polling sources that support it. + PriorityLabels []string + // Comments is the status-comment configuration, or nil when comment + // reporting is off. The deprecated GitHub reporting.enabled flag maps to + // PerTask comments. + Comments *CommentsReporting + // Checks is the GitHub Check Run configuration, or nil. + Checks *GitHubChecksReporting +} + +// CommentMode returns the effective comment mode: the configured one, or +// PerTask when comments are enabled without an explicit mode. +func (t TrackerSource) CommentMode() CommentMode { + if t.Comments != nil && t.Comments.Mode != "" { + return t.Comments.Mode + } + return CommentModePerTask +} + +// Tracker returns the code-host view of the configured source. ok is false +// when the source is not backed by a code host (cron, jira, linear, generic +// webhook, slack) or when no source is set. +func (w When) Tracker() (TrackerSource, bool) { + switch { + case w.GitHubIssues != nil: + return githubTracker(w.GitHubIssues.Repo, w.GitHubIssues.PriorityLabels, w.GitHubIssues.Reporting, false), true + case w.GitHubPullRequests != nil: + return githubTracker(w.GitHubPullRequests.Repo, w.GitHubPullRequests.PriorityLabels, w.GitHubPullRequests.Reporting, false), true + case w.GitHubWebhook != nil: + return githubTracker(w.GitHubWebhook.Repository, nil, w.GitHubWebhook.Reporting, true), true + case w.GitLab != nil: + return gitlabTracker(w.GitLab.Project, w.GitLab.Reporting, false), true + case w.GitLabWebhook != nil: + return gitlabTracker(w.GitLabWebhook.Project, w.GitLabWebhook.Reporting, true), true + } + return TrackerSource{}, false +} + +// PollInterval returns the configured poll interval of the polling source, or +// "" when the source uses the default or is not polled. +func (w When) PollInterval() string { + switch { + case w.GitHubIssues != nil: + return w.GitHubIssues.PollInterval + case w.GitHubPullRequests != nil: + return w.GitHubPullRequests.PollInterval + case w.Jira != nil: + return w.Jira.PollInterval + case w.GitLab != nil: + return w.GitLab.PollInterval + } + return "" +} + +func githubTracker(repo string, priorityLabels []string, reporting *GitHubReporting, webhook bool) TrackerSource { + t := TrackerSource{Provider: WorkspaceProviderGitHub, Webhook: webhook, Repo: repo, PriorityLabels: priorityLabels} + if reporting == nil { + return t + } + t.Comments = reporting.Comments + if t.Comments == nil && reporting.Enabled { + t.Comments = &CommentsReporting{Mode: CommentModePerTask} + } + t.Checks = reporting.Checks + return t +} + +func gitlabTracker(project string, reporting *GitLabReporting, webhook bool) TrackerSource { + t := TrackerSource{Provider: WorkspaceProviderGitLab, Webhook: webhook, Repo: project} + if reporting != nil { + t.Comments = reporting.Comments + } + return t +} diff --git a/api/v1alpha2/taskspawner_tracker_test.go b/api/v1alpha2/taskspawner_tracker_test.go new file mode 100644 index 000000000..cc2daf70c --- /dev/null +++ b/api/v1alpha2/taskspawner_tracker_test.go @@ -0,0 +1,116 @@ +package v1alpha2 + +import ( + "reflect" + "testing" +) + +// whenFieldTrackers records, for every When field, whether it is a code-host +// source. Adding a field to When without listing it here fails the test, so a +// new provider cannot be forgotten in Tracker(). +var whenFieldTrackers = map[string]bool{ + "GitHubIssues": true, + "GitHubPullRequests": true, + "GitHubWebhook": true, + "GitLab": true, + "GitLabWebhook": true, + "Cron": false, + "Jira": false, + "LinearWebhook": false, + "GenericWebhook": false, + "Slack": false, +} + +func TestWhenTrackerCoversEveryField(t *testing.T) { + whenType := reflect.TypeOf(When{}) + for i := 0; i < whenType.NumField(); i++ { + field := whenType.Field(i) + wantTracker, listed := whenFieldTrackers[field.Name] + if !listed { + t.Fatalf("When.%s is not classified in whenFieldTrackers; decide whether Tracker() must cover it", field.Name) + } + var when When + reflect.ValueOf(&when).Elem().Field(i).Set(reflect.New(field.Type.Elem())) + if _, ok := when.Tracker(); ok != wantTracker { + t.Errorf("When{%s}.Tracker() ok = %v, want %v", field.Name, ok, wantTracker) + } + } + if len(whenFieldTrackers) != whenType.NumField() { + t.Errorf("whenFieldTrackers lists %d fields, When has %d", len(whenFieldTrackers), whenType.NumField()) + } +} + +func TestWhenTracker(t *testing.T) { + tests := []struct { + name string + when When + want TrackerSource + }{ + { + name: "github issues with deprecated enabled flag", + when: When{GitHubIssues: &GitHubIssues{Repo: "org/upstream", PriorityLabels: []string{"p0"}, Reporting: &GitHubReporting{Enabled: true}}}, + want: TrackerSource{Provider: WorkspaceProviderGitHub, Repo: "org/upstream", PriorityLabels: []string{"p0"}, Comments: &CommentsReporting{Mode: CommentModePerTask}}, + }, + { + name: "github pull requests with sticky comments and checks", + when: When{GitHubPullRequests: &GitHubPullRequests{Reporting: &GitHubReporting{ + Comments: &CommentsReporting{Mode: CommentModeSticky}, + Checks: &GitHubChecksReporting{Name: "kelos"}, + }}}, + want: TrackerSource{Provider: WorkspaceProviderGitHub, Comments: &CommentsReporting{Mode: CommentModeSticky}, Checks: &GitHubChecksReporting{Name: "kelos"}}, + }, + { + name: "github webhook restriction", + when: When{GitHubWebhook: &GitHubWebhook{Repository: "org/repo"}}, + want: TrackerSource{Provider: WorkspaceProviderGitHub, Webhook: true, Repo: "org/repo"}, + }, + { + name: "gitlab project with comments", + when: When{GitLab: &GitLab{Project: "group/repo", Reporting: &GitLabReporting{Comments: &CommentsReporting{}}}}, + want: TrackerSource{Provider: WorkspaceProviderGitLab, Repo: "group/repo", Comments: &CommentsReporting{}}, + }, + { + name: "gitlab webhook without reporting", + when: When{GitLabWebhook: &GitLabWebhook{Project: "group/repo"}}, + want: TrackerSource{Provider: WorkspaceProviderGitLab, Webhook: true, Repo: "group/repo"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := tt.when.Tracker() + if !ok { + t.Fatal("Tracker() ok = false, want true") + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Tracker() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestTrackerSourceCommentMode(t *testing.T) { + if got := (TrackerSource{}).CommentMode(); got != CommentModePerTask { + t.Errorf("CommentMode() without comments = %q, want PerTask", got) + } + if got := (TrackerSource{Comments: &CommentsReporting{}}).CommentMode(); got != CommentModePerTask { + t.Errorf("CommentMode() with empty mode = %q, want PerTask", got) + } + if got := (TrackerSource{Comments: &CommentsReporting{Mode: CommentModeSticky}}).CommentMode(); got != CommentModeSticky { + t.Errorf("CommentMode() = %q, want Sticky", got) + } +} + +func TestWhenPollInterval(t *testing.T) { + tests := map[string]When{ + "2m": {GitHubIssues: &GitHubIssues{PollInterval: "2m"}}, + "3m": {GitHubPullRequests: &GitHubPullRequests{PollInterval: "3m"}}, + "4m": {Jira: &Jira{PollInterval: "4m"}}, + "30s": {GitLab: &GitLab{PollInterval: "30s"}}, + "": {GitHubWebhook: &GitHubWebhook{}}, + } + for want, when := range tests { + if got := when.PollInterval(); got != want { + t.Errorf("PollInterval() = %q, want %q", got, want) + } + } +} diff --git a/api/v1alpha2/taskspawner_types.go b/api/v1alpha2/taskspawner_types.go index ff115a974..a64b9ca53 100644 --- a/api/v1alpha2/taskspawner_types.go +++ b/api/v1alpha2/taskspawner_types.go @@ -94,7 +94,7 @@ type GitHubReporting struct { // or pull request. When nil, no comments are posted unless the deprecated // Enabled field is true. // +optional - Comments *GitHubCommentsReporting `json:"comments,omitempty"` + Comments *CommentsReporting `json:"comments,omitempty"` // Checks creates GitHub Check Runs for pull request tasks. When nil, // no Check Runs are created. Supported for githubPullRequests and @@ -117,10 +117,11 @@ const ( CommentModeSticky CommentMode = "Sticky" ) -// GitHubCommentsReporting configures GitHub task status comment reporting. -type GitHubCommentsReporting struct { +// CommentsReporting configures task status comment reporting on the +// originating issue, pull request, or merge request. +type CommentsReporting struct { // Mode controls whether comments are created per Task or reused across - // Tasks from the same TaskSpawner and originating issue or pull request. + // Tasks from the same TaskSpawner and originating item. // Defaults to PerTask. // +optional // +kubebuilder:default=PerTask @@ -418,18 +419,7 @@ type GitLabReporting struct { // Comments configures task status notes on the originating issue or // merge request. When nil, no notes are posted. // +optional - Comments *GitLabCommentsReporting `json:"comments,omitempty"` -} - -// GitLabCommentsReporting configures GitLab task status note reporting. -type GitLabCommentsReporting struct { - // Mode controls whether notes are created per Task or reused across - // Tasks from the same TaskSpawner and originating issue or merge request. - // Defaults to PerTask. - // +optional - // +kubebuilder:default=PerTask - // +kubebuilder:validation:Enum=PerTask;Sticky - Mode CommentMode `json:"mode,omitempty"` + Comments *CommentsReporting `json:"comments,omitempty"` } // GitLab discovers issues and merge requests from a GitLab project. diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index bd899dbbe..ea1269d95 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -166,6 +166,21 @@ func (in *BudgetPeriod) DeepCopy() *BudgetPeriod { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CommentsReporting) DeepCopyInto(out *CommentsReporting) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommentsReporting. +func (in *CommentsReporting) DeepCopy() *CommentsReporting { + if in == nil { + return nil + } + out := new(CommentsReporting) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ContextSource) DeepCopyInto(out *ContextSource) { *out = *in @@ -398,21 +413,6 @@ func (in *GitHubCommentPolicy) DeepCopy() *GitHubCommentPolicy { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GitHubCommentsReporting) DeepCopyInto(out *GitHubCommentsReporting) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubCommentsReporting. -func (in *GitHubCommentsReporting) DeepCopy() *GitHubCommentsReporting { - if in == nil { - return nil - } - out := new(GitHubCommentsReporting) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitHubGateway) DeepCopyInto(out *GitHubGateway) { *out = *in @@ -544,7 +544,7 @@ func (in *GitHubReporting) DeepCopyInto(out *GitHubReporting) { *out = *in if in.Comments != nil { in, out := &in.Comments, &out.Comments - *out = new(GitHubCommentsReporting) + *out = new(CommentsReporting) **out = **in } if in.Checks != nil { @@ -716,21 +716,6 @@ func (in *GitLabCommentPolicy) DeepCopy() *GitLabCommentPolicy { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GitLabCommentsReporting) DeepCopyInto(out *GitLabCommentsReporting) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitLabCommentsReporting. -func (in *GitLabCommentsReporting) DeepCopy() *GitLabCommentsReporting { - if in == nil { - return nil - } - out := new(GitLabCommentsReporting) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitLabGateway) DeepCopyInto(out *GitLabGateway) { *out = *in @@ -757,7 +742,7 @@ func (in *GitLabReporting) DeepCopyInto(out *GitLabReporting) { *out = *in if in.Comments != nil { in, out := &in.Comments, &out.Comments - *out = new(GitLabCommentsReporting) + *out = new(CommentsReporting) **out = **in } } @@ -2320,6 +2305,36 @@ func (in *TaskUsage) DeepCopy() *TaskUsage { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TrackerSource) DeepCopyInto(out *TrackerSource) { + *out = *in + if in.PriorityLabels != nil { + in, out := &in.PriorityLabels, &out.PriorityLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Comments != nil { + in, out := &in.Comments, &out.Comments + *out = new(CommentsReporting) + **out = **in + } + if in.Checks != nil { + in, out := &in.Checks, &out.Checks + *out = new(GitHubChecksReporting) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrackerSource. +func (in *TrackerSource) DeepCopy() *TrackerSource { + if in == nil { + return nil + } + out := new(TrackerSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WebhookGateway) DeepCopyInto(out *WebhookGateway) { *out = *in diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 9b741ad2e..e23db7466 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -627,7 +627,8 @@ func recordCycleFailure(ctx context.Context, cl client.Client, key types.Namespa // (such as the reporting watcher) to identify the originating issue, pull // request, or merge request. func sourceAnnotations(ts *kelos.TaskSpawner, item source.WorkItem) map[string]string { - if ts.Spec.When.GitHubIssues == nil && ts.Spec.When.GitHubPullRequests == nil && ts.Spec.When.GitLab == nil { + tracker, ok := ts.Spec.When.Tracker() + if !ok { return nil } @@ -644,77 +645,37 @@ func sourceAnnotations(ts *kelos.TaskSpawner, item source.WorkItem) map[string]s reporting.AnnotationSourceNumber: strconv.Itoa(item.Number), } - if reportingEnabled(ts) { + if tracker.Comments != nil { annotations[reporting.AnnotationGitHubReporting] = "enabled" - annotations[reporting.AnnotationGitHubCommentMode] = string(resolvedCommentMode(ts)) + annotations[reporting.AnnotationGitHubCommentMode] = string(tracker.CommentMode()) } - if checksReportingEnabled(ts) { + if tracker.Checks != nil { annotations[reporting.AnnotationGitHubChecks] = "enabled" if item.HeadSHA != "" { annotations[reporting.AnnotationSourceSHA] = item.HeadSHA } - if name := resolvedCheckName(ts); name != "" { - annotations[reporting.AnnotationGitHubCheckName] = name + if tracker.Checks.Name != "" { + annotations[reporting.AnnotationGitHubCheckName] = tracker.Checks.Name } } return annotations } -// reportingEnabled returns true when GitHub or GitLab comment reporting is -// configured and enabled on the TaskSpawner. This only covers polling-based -// sources; webhook-based reporting is handled by the webhook server and its -// handler. +// reportingEnabled returns true when comment reporting is configured on the +// TaskSpawner's code-host source. This only covers polling-based sources; +// webhook-based reporting is handled by the webhook server and its handler. func reportingEnabled(ts *kelos.TaskSpawner) bool { - if ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Reporting != nil { - rep := ts.Spec.When.GitHubIssues.Reporting - return rep.Enabled || rep.Comments != nil - } - if ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.Reporting != nil { - rep := ts.Spec.When.GitHubPullRequests.Reporting - return rep.Enabled || rep.Comments != nil - } - if ts.Spec.When.GitLab != nil && ts.Spec.When.GitLab.Reporting != nil { - return ts.Spec.When.GitLab.Reporting.Comments != nil - } - return false -} - -// resolvedCommentMode returns the configured comment mode. The deprecated -// Enabled field and an empty Comments configuration retain PerTask behavior. -func resolvedCommentMode(ts *kelos.TaskSpawner) kelos.CommentMode { - var mode kelos.CommentMode - switch { - case ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Reporting != nil && ts.Spec.When.GitHubIssues.Reporting.Comments != nil: - mode = ts.Spec.When.GitHubIssues.Reporting.Comments.Mode - case ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.Reporting != nil && ts.Spec.When.GitHubPullRequests.Reporting.Comments != nil: - mode = ts.Spec.When.GitHubPullRequests.Reporting.Comments.Mode - case ts.Spec.When.GitLab != nil && ts.Spec.When.GitLab.Reporting != nil && ts.Spec.When.GitLab.Reporting.Comments != nil: - mode = ts.Spec.When.GitLab.Reporting.Comments.Mode - } - if mode != "" { - return mode - } - return kelos.CommentModePerTask + tracker, _ := ts.Spec.When.Tracker() + return tracker.Comments != nil } // checksReportingEnabled returns true when GitHub Checks API reporting is -// configured and enabled on the TaskSpawner. +// configured on the TaskSpawner. func checksReportingEnabled(ts *kelos.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 *kelos.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 "" + tracker, _ := ts.Spec.When.Tracker() + return tracker.Checks != nil } type resolvedGitHubCommentPolicy struct { @@ -930,26 +891,19 @@ func newGitLabTokenResolver(token string) func(context.Context) (string, error) } func priorityLabelsForTaskSpawner(ts *kelos.TaskSpawner) []string { - if ts.Spec.When.GitHubIssues != nil { - return ts.Spec.When.GitHubIssues.PriorityLabels - } - if ts.Spec.When.GitHubPullRequests != nil { - return ts.Spec.When.GitHubPullRequests.PriorityLabels - } - return nil + tracker, _ := ts.Spec.When.Tracker() + return tracker.PriorityLabels } -// deriveUpstreamRepo extracts the owner/repo from the githubIssues.repo or -// githubPullRequests.repo override, returning it in "owner/repo" format. -// Returns an empty string when no override is configured. +// deriveUpstreamRepo extracts the owner/repo from a GitHub source's repo +// override, returning it in "owner/repo" format. Returns an empty string when +// no override is configured. func deriveUpstreamRepo(ts *kelos.TaskSpawner) string { - var repoOverride string - if ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Repo != "" { - repoOverride = ts.Spec.When.GitHubIssues.Repo - } else if ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.Repo != "" { - repoOverride = ts.Spec.When.GitHubPullRequests.Repo + tracker, _ := ts.Spec.When.Tracker() + if tracker.Provider != kelos.WorkspaceProviderGitHub { + return "" } - return source.GitHubRepositoryName(repoOverride) + return source.GitHubRepositoryName(tracker.Repo) } func parsePollInterval(s string) time.Duration { diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index c2c884367..f24bc1f69 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -2079,7 +2079,7 @@ func TestSourceAnnotations_GitLab(t *testing.T) { When: kelos.When{ GitLab: &kelos.GitLab{ Reporting: &kelos.GitLabReporting{ - Comments: &kelos.GitLabCommentsReporting{Mode: kelos.CommentModeSticky}, + Comments: &kelos.CommentsReporting{Mode: kelos.CommentModeSticky}, }, }, }, @@ -2354,7 +2354,7 @@ func TestReportingEnabled_CommentsConfigured(t *testing.T) { Spec: kelos.TaskSpawnerSpec{ When: kelos.When{ GitHubIssues: &kelos.GitHubIssues{ - Reporting: &kelos.GitHubReporting{Comments: &kelos.GitHubCommentsReporting{}}, + Reporting: &kelos.GitHubReporting{Comments: &kelos.CommentsReporting{}}, }, }, }, @@ -2430,13 +2430,13 @@ func TestReportingEnabled_GitLab(t *testing.T) { } enabled := &kelos.TaskSpawner{Spec: kelos.TaskSpawnerSpec{When: kelos.When{GitLab: &kelos.GitLab{ - Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{}}, + Reporting: &kelos.GitLabReporting{Comments: &kelos.CommentsReporting{}}, }}}} if !reportingEnabled(enabled) { t.Error("Expected reporting to be enabled for GitLab comments reporting") } - if got := resolvedCommentMode(enabled); got != kelos.CommentModePerTask { - t.Errorf("resolvedCommentMode = %q, want PerTask default", got) + if got := sourceAnnotations(enabled, source.WorkItem{Kind: "MR", Number: 1})[reporting.AnnotationGitHubCommentMode]; got != string(kelos.CommentModePerTask) { + t.Errorf("comment mode annotation = %q, want PerTask default", got) } } @@ -2566,7 +2566,7 @@ func TestSourceAnnotations_StickyComments(t *testing.T) { When: kelos.When{ GitHubPullRequests: &kelos.GitHubPullRequests{ Reporting: &kelos.GitHubReporting{ - Comments: &kelos.GitHubCommentsReporting{Mode: kelos.CommentModeSticky}, + Comments: &kelos.CommentsReporting{Mode: kelos.CommentModeSticky}, }, }, }, @@ -2827,7 +2827,7 @@ func TestRunOnce_GitLabReportingUsesGitLabTokenResolver(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) ts.Spec.Suspend = boolPtr(true) ts.Spec.When = kelos.When{GitLab: &kelos.GitLab{ - Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{}}, + Reporting: &kelos.GitLabReporting{Comments: &kelos.CommentsReporting{}}, }} cl, key := setupTest(t, ts) diff --git a/cmd/kelos-spawner/reconciler.go b/cmd/kelos-spawner/reconciler.go index 9f44deb4a..b9291931c 100644 --- a/cmd/kelos-spawner/reconciler.go +++ b/cmd/kelos-spawner/reconciler.go @@ -96,7 +96,7 @@ func runOnce(ctx context.Context, cl client.Client, key types.NamespacedName, cf } // Reporting always uses the direct API base URL (writes bypass the proxy). reporter := &reporting.TaskReporter{Client: cl} - if ts.Spec.When.GitLab != nil { + if tracker, _ := ts.Spec.When.Tracker(); tracker.Provider == kelos.WorkspaceProviderGitLab { reporter.Reporter = &reporting.GitLabReporter{ BaseURL: cfg.GitLabBaseURL, Project: cfg.GitLabProject, @@ -130,24 +130,10 @@ func runOnce(ctx context.Context, cl client.Client, key types.NamespacedName, cf return resolvedPollInterval(&ts), nil } -// resolvedPollInterval returns the effective poll interval for the TaskSpawner. -// It checks the active source's PollInterval first, falling back to the default. +// resolvedPollInterval returns the effective poll interval for the TaskSpawner: +// the active source's PollInterval, falling back to the default. func resolvedPollInterval(ts *kelos.TaskSpawner) time.Duration { - var sourceInterval string - switch { - case ts.Spec.When.GitHubIssues != nil: - sourceInterval = ts.Spec.When.GitHubIssues.PollInterval - case ts.Spec.When.GitHubPullRequests != nil: - sourceInterval = ts.Spec.When.GitHubPullRequests.PollInterval - case ts.Spec.When.Jira != nil: - sourceInterval = ts.Spec.When.Jira.PollInterval - case ts.Spec.When.GitLab != nil: - sourceInterval = ts.Spec.When.GitLab.PollInterval - } - if sourceInterval != "" { - return parsePollInterval(sourceInterval) - } - return parsePollInterval("") + return parsePollInterval(ts.Spec.When.PollInterval()) } func (r *spawnerReconciler) requestsForTask(_ context.Context, obj client.Object) []reconcile.Request { diff --git a/internal/cli/printer.go b/internal/cli/printer.go index 6272a5f12..ef93c7f40 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -255,15 +255,8 @@ func printTaskSpawnerTable(w io.Writer, spawners []kelos.TaskSpawner, allNamespa // uses, mirroring the resolution in cmd/kelos-spawner: the active source's // pollInterval takes precedence over the default interval. func effectivePollInterval(ts *kelos.TaskSpawner) string { - switch { - case ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.PollInterval != "": - return ts.Spec.When.GitHubIssues.PollInterval - case ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.PollInterval != "": - return ts.Spec.When.GitHubPullRequests.PollInterval - case ts.Spec.When.Jira != nil && ts.Spec.When.Jira.PollInterval != "": - return ts.Spec.When.Jira.PollInterval - case ts.Spec.When.GitLab != nil && ts.Spec.When.GitLab.PollInterval != "": - return ts.Spec.When.GitLab.PollInterval + if interval := ts.Spec.When.PollInterval(); interval != "" { + return interval } return "5m" } diff --git a/internal/cli/run_from_taskspawner.go b/internal/cli/run_from_taskspawner.go index ce74415c8..bc1608ec7 100644 --- a/internal/cli/run_from_taskspawner.go +++ b/internal/cli/run_from_taskspawner.go @@ -223,13 +223,11 @@ func taskSpawnerHTTPClient(configured *http.Client) *http.Client { } func taskSpawnerUpstreamRepo(spawner *kelos.TaskSpawner) string { - var repo string - if spawner.Spec.When.GitHubIssues != nil { - repo = spawner.Spec.When.GitHubIssues.Repo - } else if spawner.Spec.When.GitHubPullRequests != nil { - repo = spawner.Spec.When.GitHubPullRequests.Repo + tracker, _ := spawner.Spec.When.Tracker() + if tracker.Provider != kelos.WorkspaceProviderGitHub || tracker.Webhook { + return "" } - return source.GitHubRepositoryName(repo) + return source.GitHubRepositoryName(tracker.Repo) } func manualTaskName(spawnerName, suffix string) string { diff --git a/internal/controller/taskspawner_deployment_builder.go b/internal/controller/taskspawner_deployment_builder.go index b435bf5de..c8c0d86bc 100644 --- a/internal/controller/taskspawner_deployment_builder.go +++ b/internal/controller/taskspawner_deployment_builder.go @@ -60,8 +60,8 @@ func (b *DeploymentBuilder) buildPodParts(ts *kelos.TaskSpawner, workspace *kelo var envVars []corev1.EnvVar if workspace != nil { - if gl := ts.Spec.When.GitLab; gl != nil { - args = append(args, gitLabSourceArgs(gl, workspace.Repo)...) + if tracker, _ := ts.Spec.When.Tracker(); tracker.Provider == kelos.WorkspaceProviderGitLab { + args = append(args, gitLabSourceArgs(ts.Spec.When.GitLab, workspace.Repo)...) } else { host, owner, repo := parseGitHubRepo(workspace.Repo) @@ -354,27 +354,28 @@ func parseGitHubOwnerRepo(repoURL string) (owner, repo string) { return owner, repo } +// githubSourceRepoOverride returns the repository a GitHub polling source +// polls instead of the Workspace repository, or "". func githubSourceRepoOverride(ts *kelos.TaskSpawner) string { - if ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.Repo != "" { - return ts.Spec.When.GitHubIssues.Repo - } - if ts.Spec.When.GitHubPullRequests != nil && ts.Spec.When.GitHubPullRequests.Repo != "" { - return ts.Spec.When.GitHubPullRequests.Repo + tracker, _ := ts.Spec.When.Tracker() + if tracker.Provider != kelos.WorkspaceProviderGitHub || tracker.Webhook { + return "" } - return "" + return tracker.Repo } // taskSpawnerNeedsWorkspaceToken reports whether the spawner needs the // workspace token for API calls: GitHub sources unless a ghproxy fronts them // (reporting still needs it), and GitLab sources always. func taskSpawnerNeedsWorkspaceToken(ts *kelos.TaskSpawner, ghProxyConfigured bool) bool { - if ts.Spec.When.GitHubIssues != nil { - return !ghProxyConfigured || gitHubReportingNeedsToken(ts.Spec.When.GitHubIssues.Reporting) + tracker, ok := ts.Spec.When.Tracker() + if !ok || tracker.Webhook { + return false } - if ts.Spec.When.GitHubPullRequests != nil { - return !ghProxyConfigured || gitHubReportingNeedsToken(ts.Spec.When.GitHubPullRequests.Reporting) + if tracker.Provider == kelos.WorkspaceProviderGitHub { + return !ghProxyConfigured || tracker.Comments != nil || tracker.Checks != nil } - return ts.Spec.When.GitLab != nil + return true } // gitLabSourceArgs returns the spawner flags for a GitLab source. The @@ -426,10 +427,6 @@ func parseGitLabRepo(repoURL string) (baseURL, project string) { return (&url.URL{Scheme: scheme, Host: parsed.Host}).String(), strings.Trim(parsed.Path, "/") } -func gitHubReportingNeedsToken(reporting *kelos.GitHubReporting) bool { - return reporting != nil && (reporting.Enabled || reporting.Comments != nil || reporting.Checks != nil) -} - func workspaceUsesGHProxy(workspace *kelos.WorkspaceSpec) bool { return workspace != nil && workspace.GHProxy != nil } diff --git a/internal/controller/taskspawner_deployment_builder_test.go b/internal/controller/taskspawner_deployment_builder_test.go index 9697cc5b4..242ac79d0 100644 --- a/internal/controller/taskspawner_deployment_builder_test.go +++ b/internal/controller/taskspawner_deployment_builder_test.go @@ -410,10 +410,10 @@ func TestDeploymentBuilder_GitHubTokenWhenGHProxyDisabled(t *testing.T) { func enableGitHubReporting(ts *kelos.TaskSpawner) { if ts.Spec.When.GitHubIssues != nil { - ts.Spec.When.GitHubIssues.Reporting = &kelos.GitHubReporting{Comments: &kelos.GitHubCommentsReporting{}} + ts.Spec.When.GitHubIssues.Reporting = &kelos.GitHubReporting{Comments: &kelos.CommentsReporting{}} } if ts.Spec.When.GitHubPullRequests != nil { - ts.Spec.When.GitHubPullRequests.Reporting = &kelos.GitHubReporting{Comments: &kelos.GitHubCommentsReporting{}} + ts.Spec.When.GitHubPullRequests.Reporting = &kelos.GitHubReporting{Comments: &kelos.CommentsReporting{}} } } diff --git a/internal/controller/workspace_provider.go b/internal/controller/workspace_provider.go index 8da3dc33b..e07dcb9c2 100644 --- a/internal/controller/workspace_provider.go +++ b/internal/controller/workspace_provider.go @@ -183,14 +183,8 @@ func workspaceSecretTokenError(p workspaceProvider, secretName string, data map[ // taskSpawnerSourceProvider returns the workspace provider a TaskSpawner // source is bound to, or "" for sources that work with any provider. func taskSpawnerSourceProvider(ts *kelos.TaskSpawner) string { - when := ts.Spec.When - switch { - case when.GitLab != nil || when.GitLabWebhook != nil: - return kelos.WorkspaceProviderGitLab - case when.GitHubIssues != nil || when.GitHubPullRequests != nil || when.GitHubWebhook != nil: - return kelos.WorkspaceProviderGitHub - } - return "" + tracker, _ := ts.Spec.When.Tracker() + return tracker.Provider } // workspaceValidationError marks a TaskSpawner whose Workspace cannot serve diff --git a/internal/conversion/taskspawner.go b/internal/conversion/taskspawner.go index 356d29711..4bb93fc5b 100644 --- a/internal/conversion/taskspawner.go +++ b/internal/conversion/taskspawner.go @@ -60,8 +60,8 @@ type preservedGitHubCommentsReporting struct { } type preservedGitHubCommentsSource struct { - Enabled bool `json:"enabled,omitempty"` - Comments v1alpha2.GitHubCommentsReporting `json:"comments"` + Enabled bool `json:"enabled,omitempty"` + Comments v1alpha2.CommentsReporting `json:"comments"` } func taskSpawnerToHub(_ context.Context, src *v1alpha1.TaskSpawner, dst *v1alpha2.TaskSpawner) error { diff --git a/internal/conversion/taskspawner_test.go b/internal/conversion/taskspawner_test.go index 0031553e3..6ed3976e5 100644 --- a/internal/conversion/taskspawner_test.go +++ b/internal/conversion/taskspawner_test.go @@ -509,7 +509,7 @@ func TestTaskSpawnerConvert_GitHubCommentsReportingRoundTrips(t *testing.T) { t.Run(tt.name, func(t *testing.T) { hub := &v1alpha2.TaskSpawner{ObjectMeta: metav1.ObjectMeta{Name: "reporter", Namespace: "default"}} tt.configureHub(&hub.Spec.When, &v1alpha2.GitHubReporting{ - Comments: &v1alpha2.GitHubCommentsReporting{Mode: v1alpha2.CommentModeSticky}, + Comments: &v1alpha2.CommentsReporting{Mode: v1alpha2.CommentModeSticky}, }) spoke := &v1alpha1.TaskSpawner{} @@ -548,7 +548,7 @@ func TestTaskSpawnerConvert_V1Alpha1CanDisablePreservedCommentsReporting(t *test When: v1alpha2.When{ GitHubWebhook: &v1alpha2.GitHubWebhook{ Reporting: &v1alpha2.GitHubReporting{ - Comments: &v1alpha2.GitHubCommentsReporting{Mode: v1alpha2.CommentModeSticky}, + Comments: &v1alpha2.CommentsReporting{Mode: v1alpha2.CommentModeSticky}, }, }, }, diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/sessionspawner-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/sessionspawner-crd.yaml index 471ef5e10..a5c1d3f16 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/sessionspawner-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/sessionspawner-crd.yaml @@ -7515,7 +7515,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml index 40772b0cc..0bb3248e9 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/taskspawner-crd.yaml @@ -22709,7 +22709,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -22900,7 +22900,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -23146,7 +23146,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -23267,8 +23267,8 @@ spec: mode: default: PerTask description: |- - Mode controls whether notes are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or merge request. + Mode controls whether comments are created per Task or reused across + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -23460,8 +23460,8 @@ spec: mode: default: PerTask description: |- - Mode controls whether notes are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or merge request. + Mode controls whether comments are created per Task or reused across + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index dd8234fa8..879737195 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -15545,7 +15545,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -60012,7 +60012,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -60203,7 +60203,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -60449,7 +60449,7 @@ spec: default: PerTask description: |- Mode controls whether comments are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or pull request. + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -60570,8 +60570,8 @@ spec: mode: default: PerTask description: |- - Mode controls whether notes are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or merge request. + Mode controls whether comments are created per Task or reused across + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask @@ -60763,8 +60763,8 @@ spec: mode: default: PerTask description: |- - Mode controls whether notes are created per Task or reused across - Tasks from the same TaskSpawner and originating issue or merge request. + Mode controls whether comments are created per Task or reused across + Tasks from the same TaskSpawner and originating item. Defaults to PerTask. enum: - PerTask diff --git a/internal/source/comment_policy.go b/internal/source/comment_policy.go index d5b0a2b7e..9980782e2 100644 --- a/internal/source/comment_policy.go +++ b/internal/source/comment_policy.go @@ -1,6 +1,9 @@ package source -import "time" +import ( + "context" + "time" +) // commentCommands is the provider-neutral shape of a comment policy: the // trigger command that admits an item and the exclude commands that block it. @@ -13,6 +16,85 @@ func (c commentCommands) enabled() bool { return c.Trigger != "" || len(c.Excludes) > 0 } +// commentEntry is the provider-neutral shape of one comment in an item's +// thread. Sources convert their own comment types to it once, at the +// boundary, so the policy logic never sees provider types. +type commentEntry struct { + Body string + Author string + CreatedAt string + // System marks tracker-generated activity (label changes, assignments) + // that can never carry a command. + System bool +} + +// commentAuthorizer decides whether an author may issue commands. It may +// call the tracker's API, so it is only consulted for comments that carry a +// command. +type commentAuthorizer interface { + isAuthorized(ctx context.Context, author string) (bool, error) +} + +// evaluateCommentPolicy reports whether an item passes its comment policy +// and, when a trigger comment matched, that comment's creation time so a +// re-trigger on a finished item can be detected. A trigger in the body counts +// but carries no time. +func evaluateCommentPolicy(ctx context.Context, cmds commentCommands, body, author string, comments []commentEntry, authorizer commentAuthorizer) (bool, time.Time, error) { + if !cmds.enabled() { + return true, time.Time{}, nil + } + + bodyHasTrigger := cmds.Trigger != "" && containsCommand(body, cmds.Trigger) + bodyHasExclude := len(cmds.Excludes) > 0 && containsAnyCommand(body, cmds.Excludes) + var bodyMatches bodyMatch + if bodyHasTrigger || bodyHasExclude { + authorized, err := authorizer.isAuthorized(ctx, author) + if err != nil { + return false, time.Time{}, err + } + if authorized { + bodyMatches = bodyMatch{trigger: bodyHasTrigger, exclude: bodyHasExclude} + } + } + + triggerMatch, excludeMatch := newCommentMatch(), newCommentMatch() + var err error + if cmds.Trigger != "" { + triggerMatch, err = latestAuthorizedComment(ctx, comments, []string{cmds.Trigger}, authorizer) + if err != nil { + return false, time.Time{}, err + } + } + if len(cmds.Excludes) > 0 { + excludeMatch, err = latestAuthorizedComment(ctx, comments, cmds.Excludes, authorizer) + if err != nil { + return false, time.Time{}, err + } + } + + allowed, triggerTime := decideCommentPolicy(cmds, bodyMatches, triggerMatch, excludeMatch) + return allowed, triggerTime, nil +} + +// latestAuthorizedComment finds the most recent comment carrying one of the +// commands from an authorized author. System comments never match. +func latestAuthorizedComment(ctx context.Context, comments []commentEntry, commands []string, authorizer commentAuthorizer) (commentMatch, error) { + match := newCommentMatch() + for i, comment := range comments { + if comment.System || !containsAnyCommand(comment.Body, commands) { + continue + } + authorized, err := authorizer.isAuthorized(ctx, comment.Author) + if err != nil { + return commentMatch{}, err + } + if authorized { + match.record(i, comment.CreatedAt) + } + } + return match, nil +} + // bodyMatch records which commands the item body itself carries from an // authorized author. A body trigger admits the item but carries no time. type bodyMatch struct { diff --git a/internal/source/github.go b/internal/source/github.go index fc796e013..7c6cbcb3b 100644 --- a/internal/source/github.go +++ b/internal/source/github.go @@ -2,9 +2,7 @@ package source import ( "context" - "encoding/json" "fmt" - "io" "net/http" "net/url" "regexp" @@ -80,15 +78,70 @@ func (s *GitHubSource) httpClient() *http.Client { return http.DefaultClient } +func (s *GitHubSource) rest() restClient { + return githubREST(s.Token, s.Client) +} + +// githubREST returns the REST plumbing for the GitHub API: token auth and +// Link-header pagination. +func githubREST(token string, client *http.Client) restClient { + return restClient{ + name: "GitHub", + client: client, + authorize: func(req *http.Request) { + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + }, + nextPage: func(_ string, resp *http.Response) string { + return parseNextLink(resp.Header.Get("Link")) + }, + } +} + // Discover fetches issues from GitHub and returns them as WorkItems. func (s *GitHubSource) Discover(ctx context.Context) ([]WorkItem, error) { + return discoverTracker(ctx, s) +} + +func (s *GitHubSource) list(ctx context.Context) ([]trackerItem, error) { issues, err := s.fetchAllIssues(ctx) if err != nil { return nil, err } + var items []trackerItem + for _, issue := range s.filterItems(issues) { + kind := "Issue" + if issue.PullRequest != nil { + kind = "PR" + } + items = append(items, trackerItem{ + WorkItem: WorkItem{ + ID: strconv.Itoa(issue.Number), + Number: issue.Number, + Title: issue.Title, + Body: issue.Body, + URL: issue.HTMLURL, + Labels: githubLabelNames(issue.Labels), + Kind: kind, + }, + Author: issue.User.Login, + }) + } + return items, nil +} - issues = s.filterItems(issues) +func (s *GitHubSource) enrich(ctx context.Context, item *trackerItem) (bool, []commentEntry, time.Time, error) { + comments, err := fetchGitHubIssueComments(ctx, s.rest(), s.baseURL(), s.Owner, s.Repo, item.Number) + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("fetching comments for issue #%d: %w", item.Number, err) + } + item.Comments = concatCommentBodies(comments) + return true, githubCommentEntries(comments), time.Time{}, nil +} +func (s *GitHubSource) commentPolicy(context.Context) (commentCommands, commentAuthorizer, error) { policy := githubCommentPolicy{ TriggerComment: s.TriggerComment, ExcludeComments: s.ExcludeComments, @@ -96,67 +149,29 @@ func (s *GitHubSource) Discover(ctx context.Context) ([]WorkItem, error) { AllowedTeams: s.AllowedTeams, MinimumPermission: s.MinimumPermission, } - needsCommentFilter := s.TriggerComment != "" || len(s.ExcludeComments) > 0 - var authorizer *githubCommentAuthorizer - if needsCommentFilter { - authorizer, err = newGitHubCommentAuthorizer(s.Owner, s.Repo, s.baseURL(), s.Token, s.httpClient(), policy) - if err != nil { - return nil, err - } - } - - var items []WorkItem - for _, issue := range issues { - var labels []string - for _, l := range issue.Labels { - labels = append(labels, l.Name) - } - - rawComments, err := s.fetchComments(ctx, issue.Number) - if err != nil { - return nil, fmt.Errorf("fetching comments for issue #%d: %w", issue.Number, err) - } - - comments := concatCommentBodies(rawComments) - - var triggerTime time.Time - if needsCommentFilter { - commentAllowed, resolvedTriggerTime, err := evaluateGitHubCommentPolicy(ctx, issue.Body, issue.User, rawComments, policy, authorizer) - if err != nil { - return nil, fmt.Errorf("evaluating comment policy for issue #%d: %w", issue.Number, err) - } - if !commentAllowed { - continue - } - triggerTime = resolvedTriggerTime - } - - kind := "Issue" - if issue.PullRequest != nil { - kind = "PR" - } - - item := WorkItem{ - ID: strconv.Itoa(issue.Number), - Number: issue.Number, - Title: issue.Title, - Body: issue.Body, - URL: issue.HTMLURL, - Labels: labels, - Comments: comments, - Kind: kind, - } - - // Record the timestamp of the most recent trigger comment so the - // spawner can retrigger completed tasks when a new trigger arrives. - if s.TriggerComment != "" { - item.TriggerTime = triggerTime - } + return githubCommentPolicyAuthorizer(s.Owner, s.Repo, s.baseURL(), s.Token, s.httpClient(), policy) +} - items = append(items, item) +// githubCommentPolicyAuthorizer builds the authorizer only when a command is +// configured, because it may call the GitHub API. +func githubCommentPolicyAuthorizer(owner, repo, baseURL, token string, client *http.Client, policy githubCommentPolicy) (commentCommands, commentAuthorizer, error) { + cmds := policy.commands() + if !cmds.enabled() { + return cmds, nil, nil } + authorizer, err := newGitHubCommentAuthorizer(owner, repo, baseURL, token, client, policy) + if err != nil { + return cmds, nil, err + } + return cmds, authorizer, nil +} - return items, nil +func githubLabelNames(labels []githubLabel) []string { + var names []string + for _, l := range labels { + names = append(names, l.Name) + } + return names } // containsAnyCommand reports whether body contains any of the given commands. @@ -240,20 +255,11 @@ func (s *GitHubSource) filterItems(issues []githubIssue) []githubIssue { } func (s *GitHubSource) fetchAllIssues(ctx context.Context) ([]githubIssue, error) { - var allIssues []githubIssue - - pageURL := s.buildIssuesURL() - - for page := 0; pageURL != "" && page < maxPages; page++ { - issues, nextURL, err := s.fetchIssuesPage(ctx, pageURL) - if err != nil { - return nil, err - } - allIssues = append(allIssues, issues...) - pageURL = nextURL + issues, _, err := fetchAllPages[githubIssue](ctx, s.rest(), s.buildIssuesURL()) + if err != nil { + return nil, fmt.Errorf("fetching issues: %w", err) } - - return allIssues, nil + return issues, nil } func (s *GitHubSource) buildIssuesURL() string { @@ -283,86 +289,11 @@ func (s *GitHubSource) buildIssuesURL() string { return u + "?" + params.Encode() } -func (s *GitHubSource) fetchIssuesPage(ctx context.Context, pageURL string) ([]githubIssue, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) - if err != nil { - return nil, "", fmt.Errorf("creating request: %w", err) - } - - if s.Token != "" { - req.Header.Set("Authorization", "token "+s.Token) - } - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := s.httpClient().Do(req) - if err != nil { - return nil, "", fmt.Errorf("fetching issues: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) - } - - var issues []githubIssue - if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { - return nil, "", fmt.Errorf("decoding issues: %w", err) - } - - nextURL := parseNextLink(resp.Header.Get("Link")) - - return issues, nextURL, nil -} - -func (s *GitHubSource) fetchComments(ctx context.Context, issueNumber int) ([]githubComment, error) { - var allComments []githubComment - - pageURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=100", - s.baseURL(), s.Owner, s.Repo, issueNumber) - - for page := 0; pageURL != "" && page < maxPages; page++ { - comments, nextURL, err := s.fetchCommentsPage(ctx, pageURL) - if err != nil { - return nil, err - } - allComments = append(allComments, comments...) - pageURL = nextURL - } - - return allComments, nil -} - -func (s *GitHubSource) fetchCommentsPage(ctx context.Context, pageURL string) ([]githubComment, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) - if err != nil { - return nil, "", fmt.Errorf("creating request: %w", err) - } - - if s.Token != "" { - req.Header.Set("Authorization", "token "+s.Token) - } - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := s.httpClient().Do(req) - if err != nil { - return nil, "", fmt.Errorf("fetching comments: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) - } - - var comments []githubComment - if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { - return nil, "", fmt.Errorf("decoding comments: %w", err) - } - - nextURL := parseNextLink(resp.Header.Get("Link")) - - return comments, nextURL, nil +// fetchGitHubIssueComments returns the conversation comments of an issue or +// pull request (GitHub serves both from the issues endpoint). +func fetchGitHubIssueComments(ctx context.Context, rest restClient, baseURL, owner, repo string, number int) ([]githubComment, error) { + comments, _, err := fetchAllPages[githubComment](ctx, rest, fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=100", baseURL, owner, repo, number)) + return comments, err } // concatCommentBodies joins comment bodies into a single string separated by @@ -370,36 +301,11 @@ func (s *GitHubSource) fetchCommentsPage(ctx context.Context, pageURL string) ([ // dropped from the front so that the most recent (and most relevant) comments // are preserved. func concatCommentBodies(comments []githubComment) string { - totalBytes := 0 - for _, c := range comments { - totalBytes += len(c.Body) - } - - // If within budget, return all comments. - if totalBytes <= maxCommentBytes { - parts := make([]string, len(comments)) - for i, c := range comments { - parts[i] = c.Body - } - return strings.Join(parts, "\n---\n") - } - - // Truncate from the front: keep the most recent comments. - var parts []string - remaining := maxCommentBytes - for i := len(comments) - 1; i >= 0; i-- { - if remaining-len(comments[i].Body) < 0 { - break - } - remaining -= len(comments[i].Body) - parts = append(parts, comments[i].Body) - } - - // Reverse so comments are back in chronological order. - for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { - parts[i], parts[j] = parts[j], parts[i] + parts := make([]string, len(comments)) + for i, c := range comments { + parts[i] = c.Body } - return strings.Join(parts, "\n---\n") + return concatBodies(parts) } var linkNextRe = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`) diff --git a/internal/source/github_comment_policy.go b/internal/source/github_comment_policy.go index 3d778a9bd..14b2d1fe2 100644 --- a/internal/source/github_comment_policy.go +++ b/internal/source/github_comment_policy.go @@ -110,11 +110,7 @@ func (a *githubCommentAuthorizer) authorizationConfigured() bool { return len(a.allowedUsers) > 0 || len(a.allowedTeams) > 0 || a.minimumPermission != "" } -func (a *githubCommentAuthorizer) isAuthorized(ctx context.Context, actor githubUser) (bool, error) { - return a.isAuthorizedLogin(ctx, actor.Login) -} - -func (a *githubCommentAuthorizer) isAuthorizedLogin(ctx context.Context, login string) (bool, error) { +func (a *githubCommentAuthorizer) isAuthorized(ctx context.Context, login string) (bool, error) { if a == nil || !a.authorizationConfigured() { return true, nil } @@ -255,63 +251,15 @@ func (a *githubCommentAuthorizer) getJSON(ctx context.Context, path string, out } func evaluateGitHubCommentPolicy(ctx context.Context, body string, bodyActor githubUser, comments []githubComment, policy githubCommentPolicy, authorizer *githubCommentAuthorizer) (bool, time.Time, error) { - cmds := policy.commands() - if !cmds.enabled() { - return true, time.Time{}, nil - } - - bodyHasTrigger := cmds.Trigger != "" && containsCommand(body, cmds.Trigger) - bodyHasExclude := len(cmds.Excludes) > 0 && containsAnyCommand(body, cmds.Excludes) - var bodyMatches bodyMatch - if bodyHasTrigger || bodyHasExclude { - authorized, err := authorizer.isAuthorized(ctx, bodyActor) - if err != nil { - return false, time.Time{}, err - } - if authorized { - bodyMatches = bodyMatch{trigger: bodyHasTrigger, exclude: bodyHasExclude} - } - } - - triggerMatch, excludeMatch := newCommentMatch(), newCommentMatch() - var err error - if cmds.Trigger != "" { - triggerMatch, err = latestAuthorizedCommentMatch(ctx, comments, []string{cmds.Trigger}, authorizer) - if err != nil { - return false, time.Time{}, err - } - } - if len(cmds.Excludes) > 0 { - excludeMatch, err = latestAuthorizedCommentMatch(ctx, comments, cmds.Excludes, authorizer) - if err != nil { - return false, time.Time{}, err - } - } - - allowed, triggerTime := decideCommentPolicy(cmds, bodyMatches, triggerMatch, excludeMatch) - return allowed, triggerTime, nil + return evaluateCommentPolicy(ctx, policy.commands(), body, bodyActor.Login, githubCommentEntries(comments), authorizer) } -func latestAuthorizedCommentMatch(ctx context.Context, comments []githubComment, commands []string, authorizer *githubCommentAuthorizer) (commentMatch, error) { - match := newCommentMatch() - - for i, comment := range comments { - if !containsAnyCommand(comment.Body, commands) { - continue - } - - authorized, err := authorizer.isAuthorized(ctx, comment.User) - if err != nil { - return commentMatch{}, err - } - if !authorized { - continue - } - - match.record(i, comment.CreatedAt) +func githubCommentEntries(comments []githubComment) []commentEntry { + entries := make([]commentEntry, len(comments)) + for i, c := range comments { + entries[i] = commentEntry{Body: c.Body, Author: c.User.Login, CreatedAt: c.CreatedAt} } - - return match, nil + return entries } func normalizeGitHubLogin(login string) string { diff --git a/internal/source/github_comment_policy_test.go b/internal/source/github_comment_policy_test.go index 2166ceabd..9f0aa2813 100644 --- a/internal/source/github_comment_policy_test.go +++ b/internal/source/github_comment_policy_test.go @@ -264,18 +264,18 @@ func TestGitHubCommentAuthorizer_MinimumPermission(t *testing.T) { } for _, tt := range tests { - got, err := authorizer.isAuthorizedLogin(context.Background(), tt.login) + got, err := authorizer.isAuthorized(context.Background(), tt.login) if err != nil { - t.Fatalf("isAuthorizedLogin(%q) error = %v", tt.login, err) + t.Fatalf("isAuthorized(%q) error = %v", tt.login, err) } if got != tt.want { - t.Fatalf("isAuthorizedLogin(%q) = %v, want %v", tt.login, got, tt.want) + t.Fatalf("isAuthorized(%q) = %v, want %v", tt.login, got, tt.want) } } // The second lookup should hit the cache. - if _, err := authorizer.isAuthorizedLogin(context.Background(), "bob"); err != nil { - t.Fatalf("cached isAuthorizedLogin() error = %v", err) + if _, err := authorizer.isAuthorized(context.Background(), "bob"); err != nil { + t.Fatalf("cached isAuthorized() error = %v", err) } if permissionChecks != 3 { t.Fatalf("permission checks = %d, want %d", permissionChecks, 3) @@ -311,13 +311,13 @@ func TestGitHubCommentAuthorizer_DoesNotCacheErrors(t *testing.T) { t.Fatalf("newGitHubCommentAuthorizer() error = %v", err) } - if _, err := authorizer.isAuthorizedLogin(context.Background(), "alice"); err == nil { + if _, err := authorizer.isAuthorized(context.Background(), "alice"); err == nil { t.Fatal("Expected first permission lookup to fail") } - got, err := authorizer.isAuthorizedLogin(context.Background(), "alice") + got, err := authorizer.isAuthorized(context.Background(), "alice") if err != nil { - t.Fatalf("second isAuthorizedLogin() error = %v", err) + t.Fatalf("second isAuthorized() error = %v", err) } if !got { t.Fatal("Expected second permission lookup to authorize alice") @@ -352,11 +352,11 @@ func TestGitHubCommentAuthorizer_AllowedTeams(t *testing.T) { t.Fatalf("newGitHubCommentAuthorizer() error = %v", err) } - if got, err := authorizer.isAuthorizedLogin(context.Background(), "alice"); err != nil || !got { - t.Fatalf("isAuthorizedLogin(alice) = %v, %v, want true, nil", got, err) + if got, err := authorizer.isAuthorized(context.Background(), "alice"); err != nil || !got { + t.Fatalf("isAuthorized(alice) = %v, %v, want true, nil", got, err) } - if got, err := authorizer.isAuthorizedLogin(context.Background(), "mallory"); err != nil || got { - t.Fatalf("isAuthorizedLogin(mallory) = %v, %v, want false, nil", got, err) + if got, err := authorizer.isAuthorized(context.Background(), "mallory"); err != nil || got { + t.Fatalf("isAuthorized(mallory) = %v, %v, want false, nil", got, err) } } @@ -389,9 +389,9 @@ func TestGitHubCommentAuthorizer_TeamAuthorizationStillWorksAfterPermissionError t.Fatalf("newGitHubCommentAuthorizer() error = %v", err) } - got, err := authorizer.isAuthorizedLogin(context.Background(), "alice") + got, err := authorizer.isAuthorized(context.Background(), "alice") if err != nil { - t.Fatalf("isAuthorizedLogin(alice) error = %v", err) + t.Fatalf("isAuthorized(alice) error = %v", err) } if !got { t.Fatal("Expected allowed team membership to authorize alice") diff --git a/internal/source/github_pr.go b/internal/source/github_pr.go index b2c1e5cb6..79fbec336 100644 --- a/internal/source/github_pr.go +++ b/internal/source/github_pr.go @@ -2,9 +2,7 @@ package source import ( "context" - "encoding/json" "fmt" - "io" "net/http" "net/url" "strconv" @@ -132,17 +130,23 @@ type githubPullRequestComment struct { } func (s *GitHubPullRequestSource) Discover(ctx context.Context) ([]WorkItem, error) { + return discoverTracker(ctx, s) +} + +func (s *GitHubPullRequestSource) rest() restClient { + return githubREST(s.Token, s.Client) +} + +func (s *GitHubPullRequestSource) list(ctx context.Context) ([]trackerItem, error) { pullRequests, err := s.fetchAllPullRequests(ctx) if err != nil { return nil, err } - pullRequests = s.filterPullRequests(pullRequests) // File-pattern filtering runs after cheap label/author/draft filters // but before expensive per-PR review and comment fetches. - hasFileFilter := len(s.FileInclude) > 0 || len(s.FileExclude) > 0 - if hasFileFilter { + if len(s.FileInclude) > 0 || len(s.FileExclude) > 0 { var fileFiltered []githubPullRequest for _, pr := range pullRequests { files, err := s.fetchPRFiles(ctx, pr.Number) @@ -156,94 +160,68 @@ func (s *GitHubPullRequestSource) Discover(ctx context.Context) ([]WorkItem, err pullRequests = fileFiltered } - policy := githubCommentPolicy{ - TriggerComment: s.TriggerComment, - ExcludeComments: s.ExcludeComments, - AllowedUsers: s.AllowedUsers, - AllowedTeams: s.AllowedTeams, - MinimumPermission: s.MinimumPermission, - } - needsCommentFilter := s.TriggerComment != "" || len(s.ExcludeComments) > 0 - var authorizer *githubCommentAuthorizer - if needsCommentFilter { - authorizer, err = newGitHubCommentAuthorizer(s.Owner, s.Repo, s.baseURL(), s.Token, s.httpClient(), policy) - if err != nil { - return nil, err - } + items := make([]trackerItem, 0, len(pullRequests)) + for _, pr := range pullRequests { + items = append(items, trackerItem{ + WorkItem: WorkItem{ + ID: strconv.Itoa(pr.Number), + Number: pr.Number, + Title: pr.Title, + Body: pr.Body, + URL: pr.HTMLURL, + Labels: githubLabelNames(pr.Labels), + Kind: "PR", + Branch: pr.Head.Ref, + HeadSHA: pr.Head.SHA, + }, + Author: pr.User.Login, + }) } + return items, nil +} - issueSource := &GitHubSource{ - Owner: s.Owner, - Repo: s.Repo, - Token: s.Token, - BaseURL: s.BaseURL, - Client: s.Client, +// enrich gates on the aggregated review state before fetching comments, so a +// pull request that fails the gate costs one call. The policy thread covers +// conversation comments, inline review comments, and review bodies; the +// review comments exposed to templates are limited to the head commit. +func (s *GitHubPullRequestSource) enrich(ctx context.Context, item *trackerItem) (bool, []commentEntry, time.Time, error) { + reviews, err := s.fetchPullRequestReviews(ctx, item.Number) + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("fetching reviews for pull request #%d: %w", item.Number, err) } + reviewState, reviewTime := aggregatePullRequestReviewState(reviews, item.HeadSHA) + if !matchesDesiredReviewState(s.resolvedReviewState(), reviewState) { + return false, nil, time.Time{}, nil + } + item.ReviewState = reviewState - var items []WorkItem - for _, pr := range pullRequests { - reviews, err := s.fetchPullRequestReviews(ctx, pr.Number) - if err != nil { - return nil, fmt.Errorf("fetching reviews for pull request #%d: %w", pr.Number, err) - } - - reviewState, triggerTime := aggregatePullRequestReviewState(reviews, pr.Head.SHA) - if !matchesDesiredReviewState(s.resolvedReviewState(), reviewState) { - continue - } - - conversationComments, err := issueSource.fetchComments(ctx, pr.Number) - if err != nil { - return nil, fmt.Errorf("fetching comments for pull request #%d: %w", pr.Number, err) - } - - reviewComments, err := s.fetchPullRequestComments(ctx, pr.Number) - if err != nil { - return nil, fmt.Errorf("fetching review comments for pull request #%d: %w", pr.Number, err) - } - - allComments := mergeComments(conversationComments, reviewComments) - allComments = appendReviewBodies(allComments, reviews) - commentTriggerTime := time.Time{} - if needsCommentFilter { - commentAllowed, resolvedTriggerTime, err := evaluateGitHubCommentPolicy(ctx, pr.Body, pr.User, allComments, policy, authorizer) - if err != nil { - return nil, fmt.Errorf("evaluating comment policy for pull request #%d: %w", pr.Number, err) - } - if !commentAllowed { - continue - } - commentTriggerTime = resolvedTriggerTime - } - - reviewComments = filterPullRequestCommentsForCommit(reviewComments, pr.Head.SHA) - - labels := make([]string, 0, len(pr.Labels)) - for _, l := range pr.Labels { - labels = append(labels, l.Name) - } - - item := WorkItem{ - ID: strconv.Itoa(pr.Number), - Number: pr.Number, - Title: pr.Title, - Body: pr.Body, - URL: pr.HTMLURL, - Labels: labels, - Comments: concatCommentBodies(conversationComments), - Kind: "PR", - Branch: pr.Head.Ref, - HeadSHA: pr.Head.SHA, - ReviewState: reviewState, - ReviewComments: concatPullRequestReviewComments(reviewComments), - } - - item.TriggerTime = s.resolveTriggerTime(triggerTime, commentTriggerTime) + conversation, err := fetchGitHubIssueComments(ctx, s.rest(), s.baseURL(), s.Owner, s.Repo, item.Number) + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("fetching comments for pull request #%d: %w", item.Number, err) + } + reviewComments, err := s.fetchPullRequestComments(ctx, item.Number) + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("fetching review comments for pull request #%d: %w", item.Number, err) + } + item.Comments = concatCommentBodies(conversation) + item.ReviewComments = concatPullRequestReviewComments(filterPullRequestCommentsForCommit(reviewComments, item.HeadSHA)) - items = append(items, item) + thread := githubCommentEntries(appendReviewBodies(mergeComments(conversation, reviewComments), reviews)) + if s.resolvedReviewState() == reviewStateAny { + reviewTime = time.Time{} } + return true, thread, reviewTime, nil +} - return items, nil +func (s *GitHubPullRequestSource) commentPolicy(context.Context) (commentCommands, commentAuthorizer, error) { + policy := githubCommentPolicy{ + TriggerComment: s.TriggerComment, + ExcludeComments: s.ExcludeComments, + AllowedUsers: s.AllowedUsers, + AllowedTeams: s.AllowedTeams, + MinimumPermission: s.MinimumPermission, + } + return githubCommentPolicyAuthorizer(s.Owner, s.Repo, s.baseURL(), s.Token, s.httpClient(), policy) } func (s *GitHubPullRequestSource) resolvedReviewState() string { @@ -318,20 +296,11 @@ func (s *GitHubPullRequestSource) filterPullRequests(pullRequests []githubPullRe } func (s *GitHubPullRequestSource) fetchAllPullRequests(ctx context.Context) ([]githubPullRequest, error) { - var allPullRequests []githubPullRequest - - pageURL := s.buildPullRequestsURL() - - for page := 0; pageURL != "" && page < maxPages; page++ { - pullRequests, nextURL, err := s.fetchPullRequestsPage(ctx, pageURL) - if err != nil { - return nil, err - } - allPullRequests = append(allPullRequests, pullRequests...) - pageURL = nextURL + pullRequests, _, err := fetchAllPages[githubPullRequest](ctx, s.rest(), s.buildPullRequestsURL()) + if err != nil { + return nil, fmt.Errorf("fetching pull requests: %w", err) } - - return allPullRequests, nil + return pullRequests, nil } func (s *GitHubPullRequestSource) buildPullRequestsURL() string { @@ -351,51 +320,24 @@ func (s *GitHubPullRequestSource) buildPullRequestsURL() string { return u + "?" + params.Encode() } -func (s *GitHubPullRequestSource) fetchPullRequestsPage(ctx context.Context, pageURL string) ([]githubPullRequest, string, error) { - var pullRequests []githubPullRequest - nextURL, err := s.fetchGitHubPage(ctx, pageURL, &pullRequests) - if err != nil { - return nil, "", fmt.Errorf("fetching pull requests: %w", err) - } - return pullRequests, nextURL, nil +func (s *GitHubPullRequestSource) pullRequestURL(number int, resource string) string { + return fmt.Sprintf("%s/repos/%s/%s/pulls/%d/%s?per_page=100", s.baseURL(), s.Owner, s.Repo, number, resource) } func (s *GitHubPullRequestSource) fetchPullRequestReviews(ctx context.Context, number int) ([]githubPullRequestReview, error) { - var allReviews []githubPullRequestReview - - pageURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=100", - s.baseURL(), s.Owner, s.Repo, number) - - for page := 0; pageURL != "" && page < maxPages; page++ { - var reviews []githubPullRequestReview - nextURL, err := s.fetchGitHubPage(ctx, pageURL, &reviews) - if err != nil { - return nil, fmt.Errorf("fetching reviews: %w", err) - } - allReviews = append(allReviews, reviews...) - pageURL = nextURL + reviews, _, err := fetchAllPages[githubPullRequestReview](ctx, s.rest(), s.pullRequestURL(number, "reviews")) + if err != nil { + return nil, fmt.Errorf("fetching reviews: %w", err) } - - return allReviews, nil + return reviews, nil } func (s *GitHubPullRequestSource) fetchPullRequestComments(ctx context.Context, number int) ([]githubPullRequestComment, error) { - var allComments []githubPullRequestComment - - pageURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/comments?per_page=100", - s.baseURL(), s.Owner, s.Repo, number) - - for page := 0; pageURL != "" && page < maxPages; page++ { - var comments []githubPullRequestComment - nextURL, err := s.fetchGitHubPage(ctx, pageURL, &comments) - if err != nil { - return nil, fmt.Errorf("fetching review comments: %w", err) - } - allComments = append(allComments, comments...) - pageURL = nextURL + comments, _, err := fetchAllPages[githubPullRequestComment](ctx, s.rest(), s.pullRequestURL(number, "comments")) + if err != nil { + return nil, fmt.Errorf("fetching review comments: %w", err) } - - return allComments, nil + return comments, nil } type githubPullRequestFile struct { @@ -403,63 +345,22 @@ type githubPullRequestFile struct { } func (s *GitHubPullRequestSource) fetchPRFiles(ctx context.Context, number int) ([]string, error) { - var allFiles []githubPullRequestFile - - pageURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=100", - s.baseURL(), s.Owner, s.Repo, number) - - var page int - for page = 0; pageURL != "" && page < maxPages; page++ { - var files []githubPullRequestFile - nextURL, err := s.fetchGitHubPage(ctx, pageURL, &files) - if err != nil { - return nil, fmt.Errorf("fetching PR files: %w", err) - } - allFiles = append(allFiles, files...) - pageURL = nextURL + files, complete, err := fetchAllPages[githubPullRequestFile](ctx, s.rest(), s.pullRequestURL(number, "files")) + if err != nil { + return nil, fmt.Errorf("fetching PR files: %w", err) } - // A partial file list is not safe for include/exclude decisions. - if pageURL != "" && page >= maxPages { + if !complete { return nil, fmt.Errorf("PR #%d has more than %d pages of changed files; file list truncated, refusing to evaluate filters on incomplete data", number, maxPages) } - paths := make([]string, len(allFiles)) - for i, f := range allFiles { + paths := make([]string, len(files)) + for i, f := range files { paths[i] = f.Filename } return paths, nil } -func (s *GitHubPullRequestSource) fetchGitHubPage(ctx context.Context, pageURL string, out interface{}) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) - if err != nil { - return "", fmt.Errorf("creating request: %w", err) - } - - if s.Token != "" { - req.Header.Set("Authorization", "token "+s.Token) - } - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := s.httpClient().Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) - } - - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { - return "", fmt.Errorf("decoding response: %w", err) - } - - return parseNextLink(resp.Header.Get("Link")), nil -} - func (s *GitHubPullRequestSource) baseURL() string { if s.BaseURL != "" { return s.BaseURL @@ -541,14 +442,6 @@ func normalizePullRequestReviewState(state string) string { } } -func (s *GitHubPullRequestSource) resolveTriggerTime(reviewTriggerTime, commentTriggerTime time.Time) time.Time { - triggerTime := commentTriggerTime - if s.resolvedReviewState() != reviewStateAny && reviewTriggerTime.After(triggerTime) { - triggerTime = reviewTriggerTime - } - return triggerTime -} - // appendReviewBodies appends review body text from pull request reviews to the // comment list so that commands in review bodies are evaluated by the comment // filter. diff --git a/internal/source/github_pr_test.go b/internal/source/github_pr_test.go index 4a04dcb23..97b6e1f64 100644 --- a/internal/source/github_pr_test.go +++ b/internal/source/github_pr_test.go @@ -1282,17 +1282,29 @@ func TestDiscoverPullRequestsNoFileFetchWithoutFilter(t *testing.T) { } } +// TestResolvePullRequestTriggerTime checks the trigger-time merge through the +// shared pipeline: a review gate's time wins when it is newer than the +// trigger comment, and without a gate the comment time stands. func TestResolvePullRequestTriggerTime(t *testing.T) { reviewTime := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) commentTime := time.Date(2026, 1, 4, 12, 0, 0, 0, time.UTC) + thread := []commentEntry{{Body: "/kelos fix", Author: "alice", CreatedAt: commentTime.Format(time.RFC3339)}} - s := &GitHubPullRequestSource{ReviewState: "changes_requested"} - if got := s.resolveTriggerTime(reviewTime, commentTime); !got.Equal(reviewTime) { - t.Errorf("resolveTriggerTime() = %v, want %v", got, reviewTime) + gated := stubPoller{gateTime: reviewTime, thread: thread, trigger: "/kelos fix"} + items, err := discoverTracker(context.Background(), gated) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || !items[0].TriggerTime.Equal(reviewTime) { + t.Errorf("TriggerTime with review gate = %+v, want %v", items, reviewTime) } - s = &GitHubPullRequestSource{ReviewState: "any"} - if got := s.resolveTriggerTime(reviewTime, commentTime); !got.Equal(commentTime) { - t.Errorf("resolveTriggerTime() with reviewState=any = %v, want %v", got, commentTime) + ungated := stubPoller{thread: thread, trigger: "/kelos fix"} + items, err = discoverTracker(context.Background(), ungated) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || !items[0].TriggerTime.Equal(commentTime) { + t.Errorf("TriggerTime with reviewState=any = %+v, want %v", items, commentTime) } } diff --git a/internal/source/gitlab.go b/internal/source/gitlab.go index e4e4dab6d..c1c43067e 100644 --- a/internal/source/gitlab.go +++ b/internal/source/gitlab.go @@ -2,9 +2,7 @@ package source import ( "context" - "encoding/json" "fmt" - "io" "net/http" "net/url" "slices" @@ -110,11 +108,33 @@ func (s *GitLabSource) baseURL() string { return defaultGitLabBaseURL } -func (s *GitLabSource) httpClient() *http.Client { - if s.Client != nil { - return s.Client +// rest returns the REST plumbing for the GitLab API: PRIVATE-TOKEN auth and +// X-Next-Page pagination. +func (s *GitLabSource) rest() restClient { + return restClient{ + name: "GitLab", + client: s.Client, + authorize: func(req *http.Request) { + if s.Token != "" { + req.Header.Set("PRIVATE-TOKEN", s.Token) + } + req.Header.Set("Accept", "application/json") + }, + nextPage: func(pageURL string, resp *http.Response) string { + next := resp.Header.Get("X-Next-Page") + if next == "" { + return "" + } + u, err := url.Parse(pageURL) + if err != nil { + return "" + } + q := u.Query() + q.Set("page", next) + u.RawQuery = q.Encode() + return u.String() + }, } - return http.DefaultClient } // Discover fetches issues and/or merge requests from GitLab and returns them @@ -122,86 +142,84 @@ func (s *GitLabSource) httpClient() *http.Client { // issues and merge requests independently, so bare IIDs would collide when // both types are discovered by one spawner. func (s *GitLabSource) Discover(ctx context.Context) ([]WorkItem, error) { - policy := gitlabCommentPolicy{ - TriggerComment: s.TriggerComment, - ExcludeComments: s.ExcludeComments, - AllowedUsers: s.AllowedUsers, - } - authorizer := newGitLabCommentAuthorizer(policy) + return discoverTracker(ctx, s) +} +func (s *GitLabSource) list(ctx context.Context) ([]trackerItem, error) { excluded := make(map[string]struct{}, len(s.ExcludeLabels)) for _, l := range s.ExcludeLabels { excluded[l] = struct{}{} } - var items []WorkItem + var items []trackerItem for _, resource := range s.resolvedResources() { list, err := s.fetchAllItems(ctx, resource) if err != nil { return nil, err } - for _, it := range list { if hasAnyLabel(it.Labels, excluded) { continue } - - item := WorkItem{ - ID: strconv.Itoa(it.IID), - Number: it.IID, - Title: it.Title, - Body: it.Description, - URL: it.WebURL, - Labels: it.Labels, - Kind: "Issue", - Branch: it.SourceBranch, - HeadSHA: it.SHA, + item := trackerItem{ + WorkItem: WorkItem{ + ID: strconv.Itoa(it.IID), + Number: it.IID, + Title: it.Title, + Body: it.Description, + URL: it.WebURL, + Labels: it.Labels, + Kind: "Issue", + Branch: it.SourceBranch, + HeadSHA: it.SHA, + }, + Author: it.Author.Username, } - - var pipelineTriggerTime time.Time if resource == gitlabResourceMergeRequests { item.ID = "mr-" + item.ID item.Kind = "MR" - - keep, triggerTime, err := s.enrichMergeRequest(ctx, it.IID, &item) - if err != nil { - return nil, err - } - if !keep { - continue - } - pipelineTriggerTime = triggerTime - } - - notes, err := s.fetchNotes(ctx, resource, it.IID) - if err != nil { - return nil, fmt.Errorf("fetching notes for %s !%d: %w", resource, it.IID, err) } - item.Comments = concatBodies(gitlabNoteBodies(gitlabConversationNotes(notes))) - if resource == gitlabResourceMergeRequests { - item.ReviewComments = concatGitLabDiffNotes(notes) - } - - if policy.enabled() { - allowed, triggerTime := evaluateGitLabCommentPolicy(it.Description, it.Author.Username, notes, policy, authorizer) - if !allowed { - continue - } - if s.TriggerComment != "" { - item.TriggerTime = triggerTime - } - } - if pipelineTriggerTime.After(item.TriggerTime) { - item.TriggerTime = pipelineTriggerTime - } - items = append(items, item) } } - return items, nil } +// enrich gates merge requests on pipeline status and review state before +// fetching notes. The policy thread is every note; conversation notes fill +// Comments and diff notes fill ReviewComments. +func (s *GitLabSource) enrich(ctx context.Context, item *trackerItem) (bool, []commentEntry, time.Time, error) { + resource := gitlabResourceIssues + var pipelineTriggerTime time.Time + if item.Kind == "MR" { + resource = gitlabResourceMergeRequests + keep, triggerTime, err := s.enrichMergeRequest(ctx, item.Number, &item.WorkItem) + if err != nil || !keep { + return false, nil, time.Time{}, err + } + pipelineTriggerTime = triggerTime + } + + notes, err := s.fetchNotes(ctx, resource, item.Number) + if err != nil { + return false, nil, time.Time{}, fmt.Errorf("fetching notes for %s !%d: %w", resource, item.Number, err) + } + item.Comments = concatBodies(gitlabNoteBodies(gitlabConversationNotes(notes))) + if item.Kind == "MR" { + item.ReviewComments = concatGitLabDiffNotes(notes) + } + return true, gitlabCommentEntries(notes), pipelineTriggerTime, nil +} + +func (s *GitLabSource) commentPolicy(context.Context) (commentCommands, commentAuthorizer, error) { + policy := gitlabCommentPolicy{ + TriggerComment: s.TriggerComment, + ExcludeComments: s.ExcludeComments, + AllowedUsers: s.AllowedUsers, + } + return policy.commands(), newGitLabCommentAuthorizer(policy), nil +} + // enrichMergeRequest loads the head pipeline and, when a review-state gate is // configured, the approval state of a merge request. It reports whether the // merge request passes the pipeline and review gates and, for a pipeline @@ -211,7 +229,7 @@ func (s *GitLabSource) enrichMergeRequest(ctx context.Context, iid int, item *Wo // The list endpoint omits head_pipeline, so each merge request costs one // detail call. var detail gitlabItem - if err := s.getJSON(ctx, fmt.Sprintf("%s/%s/%d", s.projectURL(), gitlabResourceMergeRequests, iid), nil, &detail); err != nil { + if _, err := s.rest().getJSON(ctx, fmt.Sprintf("%s/%s/%d", s.projectURL(), gitlabResourceMergeRequests, iid), &detail); err != nil { return false, time.Time{}, fmt.Errorf("fetching merge request !%d: %w", iid, err) } @@ -262,7 +280,7 @@ func (s *GitLabSource) fetchReviewState(ctx context.Context, iid int, detailedMe } var approvals gitlabApprovals - if err := s.getJSON(ctx, fmt.Sprintf("%s/%s/%d/approvals", s.projectURL(), gitlabResourceMergeRequests, iid), nil, &approvals); err != nil { + if _, err := s.rest().getJSON(ctx, fmt.Sprintf("%s/%s/%d/approvals", s.projectURL(), gitlabResourceMergeRequests, iid), &approvals); err != nil { return "", fmt.Errorf("fetching approvals for merge request !%d: %w", iid, err) } if approvals.Approved { @@ -353,6 +371,7 @@ func (s *GitLabSource) projectURL() string { func (s *GitLabSource) fetchAllItems(ctx context.Context, resource string) ([]gitlabItem, error) { params := url.Values{} params.Set("per_page", "100") + params.Set("page", "1") state := s.State if state == "" { state = "opened" @@ -361,17 +380,8 @@ func (s *GitLabSource) fetchAllItems(ctx context.Context, resource string) ([]gi if len(s.Labels) > 0 { params.Set("labels", strings.Join(s.Labels, ",")) } - - var all []gitlabItem - err := s.fetchPages(ctx, s.projectURL()+"/"+resource, params, func(body io.Reader) (int, error) { - var page []gitlabItem - if err := json.NewDecoder(body).Decode(&page); err != nil { - return 0, err - } - all = append(all, page...) - return len(page), nil - }) - return all, err + items, _, err := fetchAllPages[gitlabItem](ctx, s.rest(), s.projectURL()+"/"+resource+"?"+params.Encode()) + return items, err } // fetchNotes returns an item's notes oldest first. They are requested newest @@ -380,82 +390,11 @@ func (s *GitLabSource) fetchAllItems(ctx context.Context, resource string) ([]gi func (s *GitLabSource) fetchNotes(ctx context.Context, resource string, iid int) ([]gitlabNote, error) { params := url.Values{} params.Set("per_page", "100") + params.Set("page", "1") params.Set("sort", "desc") params.Set("order_by", "created_at") - var all []gitlabNote - err := s.fetchPages(ctx, fmt.Sprintf("%s/%s/%d/notes", s.projectURL(), resource, iid), params, func(body io.Reader) (int, error) { - var page []gitlabNote - if err := json.NewDecoder(body).Decode(&page); err != nil { - return 0, err - } - all = append(all, page...) - return len(page), nil - }) - slices.Reverse(all) - return all, err -} - -// fetchPages walks GitLab's page-number pagination, following the X-Next-Page -// response header until it is empty or maxPages is reached. -func (s *GitLabSource) fetchPages(ctx context.Context, endpoint string, params url.Values, decode func(io.Reader) (int, error)) error { - page := "1" - for i := 0; page != "" && i < maxPages; i++ { - params.Set("page", page) - resp, err := s.get(ctx, endpoint, params) - if err != nil { - return err - } - n, err := decode(resp.Body) - resp.Body.Close() - if err != nil { - return fmt.Errorf("decoding response: %w", err) - } - if n == 0 { - break - } - page = resp.Header.Get("X-Next-Page") - } - return nil -} - -// getJSON fetches a single JSON document. -func (s *GitLabSource) getJSON(ctx context.Context, endpoint string, params url.Values, out interface{}) error { - resp, err := s.get(ctx, endpoint, params) - if err != nil { - return err - } - defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - return nil -} - -// get performs an authenticated GET and returns the response for a 200 -// status; any other status is returned as an error with the response body. -func (s *GitLabSource) get(ctx context.Context, endpoint string, params url.Values) (*http.Response, error) { - target := endpoint - if len(params) > 0 { - target += "?" + params.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - if s.Token != "" { - req.Header.Set("PRIVATE-TOKEN", s.Token) - } - req.Header.Set("Accept", "application/json") - - resp, err := s.httpClient().Do(req) - if err != nil { - return nil, fmt.Errorf("fetching %s: %w", endpoint, err) - } - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - resp.Body.Close() - return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, string(body)) - } - return resp, nil + notes, _, err := fetchAllPages[gitlabNote](ctx, s.rest(), fmt.Sprintf("%s/%s/%d/notes?%s", s.projectURL(), resource, iid, params.Encode())) + slices.Reverse(notes) + return notes, err } diff --git a/internal/source/gitlab_comment_policy.go b/internal/source/gitlab_comment_policy.go index 70ec91e6f..a8b64cc91 100644 --- a/internal/source/gitlab_comment_policy.go +++ b/internal/source/gitlab_comment_policy.go @@ -1,6 +1,7 @@ package source import ( + "context" "strings" "time" ) @@ -46,50 +47,29 @@ func newGitLabCommentAuthorizer(policy gitlabCommentPolicy) *gitlabCommentAuthor return a } -func (a *gitlabCommentAuthorizer) isAuthorized(username string) bool { +// isAuthorized never fails: the allow-list is local, so no API call is made. +func (a *gitlabCommentAuthorizer) isAuthorized(_ context.Context, username string) (bool, error) { if !a.restricted { - return true + return true, nil } _, ok := a.allowedUsers[normalizeGitLabUsername(username)] - return ok + return ok, nil } // evaluateGitLabCommentPolicy reports whether the item passes the policy and, // when a trigger note matched, the note's creation time so re-triggers on a -// finished item can be detected. A trigger in the description counts but -// carries no time. System notes never match. +// finished item can be detected. func evaluateGitLabCommentPolicy(description, author string, notes []gitlabNote, policy gitlabCommentPolicy, authorizer *gitlabCommentAuthorizer) (bool, time.Time) { - if !policy.enabled() { - return true, time.Time{} - } - - cmds := policy.commands() - var body bodyMatch - if authorizer.isAuthorized(author) { - body.trigger = cmds.Trigger != "" && containsCommand(description, cmds.Trigger) - body.exclude = len(cmds.Excludes) > 0 && containsAnyCommand(description, cmds.Excludes) - } - - triggerMatch, excludeMatch := newCommentMatch(), newCommentMatch() - if cmds.Trigger != "" { - triggerMatch = latestAuthorizedGitLabNote(notes, []string{cmds.Trigger}, authorizer) - } - if len(cmds.Excludes) > 0 { - excludeMatch = latestAuthorizedGitLabNote(notes, cmds.Excludes, authorizer) - } - - return decideCommentPolicy(cmds, body, triggerMatch, excludeMatch) + allowed, triggerTime, _ := evaluateCommentPolicy(context.Background(), policy.commands(), description, author, gitlabCommentEntries(notes), authorizer) + return allowed, triggerTime } -func latestAuthorizedGitLabNote(notes []gitlabNote, commands []string, authorizer *gitlabCommentAuthorizer) commentMatch { - match := newCommentMatch() - for i, note := range notes { - if note.System || !containsAnyCommand(note.Body, commands) || !authorizer.isAuthorized(note.Author.Username) { - continue - } - match.record(i, note.CreatedAt) +func gitlabCommentEntries(notes []gitlabNote) []commentEntry { + entries := make([]commentEntry, len(notes)) + for i, n := range notes { + entries[i] = commentEntry{Body: n.Body, Author: n.Author.Username, CreatedAt: n.CreatedAt, System: n.System} } - return match + return entries } func normalizeGitLabUsername(username string) string { diff --git a/internal/source/tracker.go b/internal/source/tracker.go new file mode 100644 index 000000000..a449b2a5a --- /dev/null +++ b/internal/source/tracker.go @@ -0,0 +1,160 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// trackerItem is a WorkItem plus the author that the comment policy judges. +// Providers convert their own API types to it once, when listing. +type trackerItem struct { + WorkItem + Author string +} + +// trackerPoller is the per-provider half of polling discovery. discoverTracker +// owns the pipeline (candidate walk, comment policy, trigger time); a provider +// owns everything that depends on one code host's API: listing and cheap +// filtering, per-item enrichment and gates, and comment retrieval. +type trackerPoller interface { + // list returns the candidate items after the provider's own cheap filters + // (labels, authors, state, draft, file patterns). + list(ctx context.Context) ([]trackerItem, error) + // enrich loads one item's detail (reviews, pipeline, comments), fills the + // item's Comments, ReviewComments, ReviewState, and pipeline fields, and + // reports whether the item passes the provider's gates. thread is what the + // comment policy evaluates; triggerTime is the provider's own + // re-engagement signal (a newer review or pipeline run), zero when no gate + // is configured. + enrich(ctx context.Context, item *trackerItem) (keep bool, thread []commentEntry, triggerTime time.Time, err error) + // commentPolicy returns the configured commands and the authorizer that + // judges their authors. The authorizer may be nil when no command is set. + commentPolicy(ctx context.Context) (commentCommands, commentAuthorizer, error) +} + +// discoverTracker runs the shared polling pipeline over a provider: list +// candidates, enrich and gate each one, apply the comment policy, and record +// the later of the provider's and the policy's trigger times so a finished +// Task is re-run when either signal is newer. +func discoverTracker(ctx context.Context, p trackerPoller) ([]WorkItem, error) { + items, err := p.list(ctx) + if err != nil { + return nil, err + } + cmds, authorizer, err := p.commentPolicy(ctx) + if err != nil { + return nil, err + } + + var out []WorkItem + for i := range items { + item := &items[i] + keep, thread, gateTime, err := p.enrich(ctx, item) + if err != nil { + return nil, err + } + if !keep { + continue + } + + var commentTime time.Time + if cmds.enabled() { + allowed, t, err := evaluateCommentPolicy(ctx, cmds, item.Body, item.Author, thread, authorizer) + if err != nil { + return nil, fmt.Errorf("evaluating comment policy for %s %s: %w", item.Kind, item.ID, err) + } + if !allowed { + continue + } + commentTime = t + } + + item.TriggerTime = commentTime + if gateTime.After(commentTime) { + item.TriggerTime = gateTime + } + out = append(out, item.WorkItem) + } + return out, nil +} + +// restClient is the HTTP plumbing shared by tracker sources: authenticated +// JSON GETs and page walking. The provider supplies the credentials header +// and how the next page is discovered. +type restClient struct { + // name labels API errors ("GitHub", "GitLab"). + name string + client *http.Client + authorize func(*http.Request) + // nextPage returns the URL of the page after resp, or "" on the last one. + nextPage func(pageURL string, resp *http.Response) string +} + +func (c restClient) httpClient() *http.Client { + if c.client != nil { + return c.client + } + return http.DefaultClient +} + +// get performs an authenticated GET and returns the response for a 200 +// status; any other status is returned as an error with the response body. +func (c restClient) get(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + c.authorize(req) + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + resp.Body.Close() + return nil, fmt.Errorf("%s API returned status %d: %s", c.name, resp.StatusCode, string(body)) + } + return resp, nil +} + +// getJSON fetches a single JSON document and returns the response headers +// for pagination. +func (c restClient) getJSON(ctx context.Context, url string, out interface{}) (http.Header, error) { + resp, err := c.get(ctx, url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + return resp.Header, nil +} + +// fetchAllPages walks a list endpoint up to maxPages and returns every +// element. complete is false when pages remained after the cap, so callers +// that cannot act on partial data can refuse. +func fetchAllPages[T any](ctx context.Context, c restClient, pageURL string) (items []T, complete bool, err error) { + for page := 0; pageURL != "" && page < maxPages; page++ { + resp, err := c.get(ctx, pageURL) + if err != nil { + return nil, false, err + } + var chunk []T + err = json.NewDecoder(resp.Body).Decode(&chunk) + resp.Body.Close() + if err != nil { + return nil, false, fmt.Errorf("decoding response: %w", err) + } + items = append(items, chunk...) + if len(chunk) == 0 { + return items, true, nil + } + pageURL = c.nextPage(pageURL, resp) + } + return items, pageURL == "", nil +} diff --git a/internal/source/tracker_test.go b/internal/source/tracker_test.go new file mode 100644 index 000000000..383ff8989 --- /dev/null +++ b/internal/source/tracker_test.go @@ -0,0 +1,157 @@ +package source + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" +) + +// stubPoller is a trackerPoller with one item that always passes the gate; +// the thread, gate time, and commands are fixed by the test. +type stubPoller struct { + gateTime time.Time + thread []commentEntry + trigger string + excludes []string + body string +} + +func (p stubPoller) list(context.Context) ([]trackerItem, error) { + return []trackerItem{{WorkItem: WorkItem{ID: "1", Number: 1, Kind: "Issue", Body: p.body}, Author: "author"}}, nil +} + +func (p stubPoller) enrich(_ context.Context, item *trackerItem) (bool, []commentEntry, time.Time, error) { + item.Comments = "enriched" + return true, p.thread, p.gateTime, nil +} + +func (p stubPoller) commentPolicy(context.Context) (commentCommands, commentAuthorizer, error) { + return commentCommands{Trigger: p.trigger, Excludes: p.excludes}, allowAll{}, nil +} + +type allowAll struct{} + +func (allowAll) isAuthorized(context.Context, string) (bool, error) { return true, nil } + +func TestDiscoverTrackerAppliesCommentPolicy(t *testing.T) { + t1 := "2026-01-01T00:00:00Z" + tests := []struct { + name string + poller stubPoller + wantItems int + wantTime string + }{ + {name: "no policy keeps the item", poller: stubPoller{}, wantItems: 1}, + {name: "trigger missing drops the item", poller: stubPoller{trigger: "/go"}, wantItems: 0}, + {name: "trigger in thread keeps the item with its time", poller: stubPoller{trigger: "/go", thread: []commentEntry{{Body: "/go", Author: "a", CreatedAt: t1}}}, wantItems: 1, wantTime: t1}, + {name: "trigger in body carries no time", poller: stubPoller{trigger: "/go", body: "/go"}, wantItems: 1}, + {name: "exclude in thread drops the item", poller: stubPoller{excludes: []string{"/stop"}, thread: []commentEntry{{Body: "/stop", Author: "a"}}}, wantItems: 0}, + {name: "enrichment survives into the work item", poller: stubPoller{}, wantItems: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + items, err := discoverTracker(context.Background(), tt.poller) + if err != nil { + t.Fatal(err) + } + if len(items) != tt.wantItems { + t.Fatalf("items = %+v, want %d", items, tt.wantItems) + } + if tt.wantItems == 0 { + return + } + if items[0].Comments != "enriched" { + t.Errorf("enrich changes must reach the WorkItem, got %+v", items[0]) + } + want, _ := time.Parse(time.RFC3339, tt.wantTime) + if !items[0].TriggerTime.Equal(want) { + t.Errorf("TriggerTime = %v, want %v", items[0].TriggerTime, want) + } + }) + } +} + +func TestFetchAllPagesStopsAtCapAndReportsCompleteness(t *testing.T) { + var mu sync.Mutex + var pages []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + mu.Lock() + pages = append(pages, page) + mu.Unlock() + n, _ := strconv.Atoi(page) + if n == 0 { + n = 1 + } + w.Header().Set("X-Next-Page", strconv.Itoa(n+1)) + json.NewEncoder(w).Encode([]int{n}) + })) + defer server.Close() + + rest := restClient{ + name: "Test", + authorize: func(*http.Request) {}, + nextPage: func(pageURL string, resp *http.Response) string { + return server.URL + "/items?page=" + resp.Header.Get("X-Next-Page") + }, + } + items, complete, err := fetchAllPages[int](context.Background(), rest, server.URL+"/items?page=1") + if err != nil { + t.Fatal(err) + } + if len(items) != maxPages || complete { + t.Errorf("items = %v, complete = %v; want %d pages and complete=false", items, complete, maxPages) + } + mu.Lock() + defer mu.Unlock() + if len(pages) != maxPages { + t.Errorf("fetched %d pages, want the %d-page cap", len(pages), maxPages) + } +} + +func TestFetchAllPagesEndsOnEmptyPage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + json.NewEncoder(w).Encode([]int{}) + return + } + json.NewEncoder(w).Encode([]int{1}) + })) + defer server.Close() + + rest := restClient{ + name: "Test", + authorize: func(*http.Request) {}, + nextPage: func(string, *http.Response) string { return server.URL + "/items?page=2" }, + } + items, complete, err := fetchAllPages[int](context.Background(), rest, server.URL+"/items?page=1") + if err != nil || len(items) != 1 || !complete { + t.Errorf("items = %v, complete = %v, err = %v; want [1], true, nil", items, complete, err) + } +} + +func TestRestClientReportsAPIErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token secret" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"bad credentials"}`)) + return + } + json.NewEncoder(w).Encode(map[string]int{"id": 7}) + })) + defer server.Close() + + rest := githubREST("secret", nil) + var out map[string]int + if _, err := rest.getJSON(context.Background(), server.URL, &out); err != nil || out["id"] != 7 { + t.Fatalf("getJSON() = %v, %v", out, err) + } + if _, err := githubREST("", nil).getJSON(context.Background(), server.URL, &out); err == nil || err.Error() != `GitHub API returned status 401: {"message":"bad credentials"}` { + t.Errorf("unexpected error %q", err) + } +} diff --git a/internal/webhook/gateway_handler.go b/internal/webhook/gateway_handler.go index e715cb8a3..88d717810 100644 --- a/internal/webhook/gateway_handler.go +++ b/internal/webhook/gateway_handler.go @@ -283,29 +283,13 @@ func (g *GatewayHandler) listGatewayScopedSpawners(ctx context.Context, namespac return nil, fmt.Errorf("listing TaskSpawners in namespace %s: %w", namespace, err) } + provider, err := providerFor(source) + if err != nil { + return nil, err + } spawners := make([]*kelos.TaskSpawner, 0) for i := range spawnerList.Items { - when := &spawnerList.Items[i].Spec.When - var ref *kelos.GatewayReference - switch source { - case GitHubSource: - if when.GitHubWebhook != nil { - ref = when.GitHubWebhook.GatewayRef - } - case LinearSource: - if when.LinearWebhook != nil { - ref = when.LinearWebhook.GatewayRef - } - case GitLabSource: - if when.GitLabWebhook != nil { - ref = when.GitLabWebhook.GatewayRef - } - case GenericSource: - if when.GenericWebhook != nil { - ref = when.GenericWebhook.GatewayRef - } - } - if ref != nil && ref.Name == name { + if ref, _ := provider.gatewayRef(&spawnerList.Items[i]); ref != nil && ref.Name == name { spawners = append(spawners, &spawnerList.Items[i]) } } diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 49cc07b29..19d3f7f3f 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "strconv" "strings" "sync" "time" @@ -22,7 +21,6 @@ import ( kelos "github.com/kelos-dev/kelos/api/v1alpha2" "github.com/kelos-dev/kelos/internal/contextfetch" - "github.com/kelos-dev/kelos/internal/reporting" "github.com/kelos-dev/kelos/internal/sessionbuilder" "github.com/kelos-dev/kelos/internal/taskbuilder" ) @@ -205,88 +203,36 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Extract headers and validate signature - var eventType, signature, deliveryID string - var genericSpawners []*kelos.TaskSpawner - - switch h.source { - case GitHubSource: - eventType = r.Header.Get(GitHubEventHeader) - signature = r.Header.Get(GitHubSignatureHeader) - deliveryID = r.Header.Get(GitHubDeliveryHeader) - if deliveryID == "" { - deliveryID = githubDeliveryID(body) - } - - log.Info("Processing GitHub webhook", "eventType", eventType, "deliveryID", deliveryID, "payloadSize", len(body)) - - if err := ValidateGitHubSignature(body, signature, h.secret); err != nil { - log.Error(err, "GitHub signature validation failed", "eventType", eventType, "deliveryID", deliveryID) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - case LinearSource: - signature = r.Header.Get(LinearSignatureHeader) - deliveryID = r.Header.Get(LinearDeliveryHeader) - eventType = "linear" // Linear doesn't send event type in header - - // If no delivery header was sent, derive delivery ID from a SHA-256 - // hash of the body so that identical retries are still deduplicated. - if deliveryID == "" { - deliveryID = linearDeliveryID(body) - } - - log.Info("Processing Linear webhook", "eventType", eventType, "deliveryID", deliveryID, "payloadSize", len(body)) - - if err := ValidateLinearSignature(body, signature, h.secret); err != nil { - log.Error(err, "Linear signature validation failed", "eventType", eventType, "deliveryID", deliveryID) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - case GitLabSource: - // The header carries a display name ("Merge Request Hook"); the - // payload object_kind is the canonical event type and is applied in - // processWebhook. - eventType = "gitlab" - deliveryID = gitlabRequestDeliveryID(r, body) - - log.Info("Processing GitLab webhook", "event", r.Header.Get(GitLabEventHeader), "deliveryID", deliveryID, "payloadSize", len(body)) + provider, err := providerFor(h.source) + if err != nil { + log.Error(err, "Unsupported webhook source") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } - if err := ValidateGitLabToken(r.Header.Get(GitLabTokenHeader), h.secret); err != nil { - log.Error(err, "GitLab token validation failed", "deliveryID", deliveryID) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return + // A provider that needs the spawner list to identify the delivery gets it + // once here, and processWebhook reuses it instead of listing again. + var listedSpawners []*kelos.TaskSpawner + listSpawners := func() []*kelos.TaskSpawner { + if listedSpawners == nil { + listedSpawners, _ = h.getMatchingSpawners(ctx) } + return listedSpawners + } - case GenericSource: - sourceName, sourceErr := extractSourceFromPath(r.URL.Path) - if sourceErr != nil { - log.Info("Invalid webhook path", "path", r.URL.Path, "error", sourceErr) - http.Error(w, sourceErr.Error(), http.StatusBadRequest) + eventType, deliveryID, err := provider.authenticate(r, body, h.secret, listSpawners) + if err != nil { + var rejection *httpError + if errors.As(err, &rejection) { + log.Info("Rejected webhook request", "error", err) + http.Error(w, rejection.message, rejection.status) return } - - eventType = sourceName - - // Single API list call provides matching spawners, avoiding a - // redundant list in processWebhook. - genericSpawners = h.getGenericSpawners(ctx) - - // Derive delivery ID from the mapped "id" field when possible so - // that retries of the same logical event deduplicate even if the - // raw JSON encoding differs. Fall back to body hash when no - // spawner maps an id for this source. - deliveryID = extractGenericDeliveryID(sourceName, body, genericSpawners) - - log.Info("Processing generic webhook", "source", sourceName, "deliveryID", deliveryID, "payloadSize", len(body)) - - default: - log.Error(fmt.Errorf("unsupported source: %s", h.source), "Unsupported webhook source") - http.Error(w, "Internal server error", http.StatusInternalServerError) + log.Error(err, "Webhook authentication failed", "eventType", eventType, "deliveryID", deliveryID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + log.Info("Processing webhook", "eventType", eventType, "deliveryID", deliveryID, "payloadSize", len(body)) // Check for duplicate delivery if deliveryID != "" && h.deliveryCache.CheckAndMark(deliveryID) { @@ -295,9 +241,7 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Process the webhook. For generic sources, pass pre-fetched spawners - // to avoid a redundant List call. - _, err = h.processWebhook(ctx, eventType, body, deliveryID, genericSpawners, nil) + _, err = h.processWebhook(ctx, eventType, body, deliveryID, listedSpawners, nil) if err != nil { if deliveryID != "" { h.deliveryCache.Forget(deliveryID) @@ -350,92 +294,32 @@ func gitlabDeliveryID(body []byte) string { // pre-scoped TaskSpawners and SessionSpawners. A non-nil slice, including an // empty one, prevents a cluster-wide list for that resource type. func (h *WebhookHandler) processWebhook(ctx context.Context, eventType string, payload []byte, deliveryID string, prefetchedSpawners []*kelos.TaskSpawner, prefetchedSessionSpawners []*kelos.SessionSpawner) (bool, error) { - log := h.log.WithValues("eventType", eventType, "deliveryID", deliveryID) + log := h.log.WithValues("deliveryID", deliveryID) - // Parse the webhook payload once up front and reuse across matching and task creation. - parsed := &ParsedWebhook{} - switch h.source { - case GitHubSource: - eventData, err := ParseGitHubWebhook(eventType, payload) - if err != nil { - return false, fmt.Errorf("failed to parse %s webhook: %w", h.source, err) - } - parsed.GitHub = eventData - parsed.ID = eventData.ID - parsed.Title = eventData.Title - if parsed.ID != "" { - log = log.WithValues("githubID", parsed.ID) - if parsed.Title != "" { - log = log.WithValues("githubTitle", parsed.Title) - } - } - - case LinearSource: - eventData, err := ParseLinearWebhook(payload) - if err != nil { - return false, fmt.Errorf("failed to parse %s webhook: %w", h.source, err) - } - parsed.Linear = eventData - parsed.ID = eventData.ID - parsed.Title = eventData.Title - // Override the generic "linear" eventType with the actual resource type - // (e.g., "Issue", "Comment") so task names are distinguishable. - if eventData.Type != "" { - eventType = strings.ToLower(eventData.Type) - } else { - log.Info("Linear webhook payload has no 'type' field, will not match any Types filter") - } - if parsed.ID != "" { - log = log.WithValues("linearID", parsed.ID) - if parsed.Title != "" { - log = log.WithValues("linearTitle", parsed.Title) - } - } - - case GitLabSource: - eventData, err := ParseGitLabWebhook(payload) - if err != nil { - return false, fmt.Errorf("failed to parse %s webhook: %w", h.source, err) - } - parsed.GitLab = eventData - parsed.ID = eventData.ID - parsed.Title = eventData.Title - // Use the payload object_kind (e.g. "merge_request", "note") as the - // event type so Task names identify the event. - if eventData.Event != "" { - eventType = eventData.Event - } else { - log.Info("GitLab webhook payload has no 'object_kind' field, will not match any Events filter") - } - if parsed.ID != "" { - log = log.WithValues("gitlabID", parsed.ID) - } - - case GenericSource: - eventData, err := ParseGenericWebhook(payload) - if err != nil { - return false, fmt.Errorf("failed to parse generic webhook: %w", err) - } - parsed.Generic = eventData - // ID and Title are extracted per-spawner via fieldMapping in matchesSpawner - log = log.WithValues("genericSource", eventType) + provider, err := providerFor(h.source) + if err != nil { + return false, err } + // Parse the webhook payload once up front and reuse across matching and task creation. + parsed, eventType, err := provider.parse(log, eventType, payload) + if err != nil { + return false, fmt.Errorf("failed to parse %s webhook: %w", h.source, err) + } + log = log.WithValues("eventType", eventType) log.Info("Processing webhook event", "resourceID", parsed.ID, "title", parsed.Title) - // Use pre-fetched spawners when available (generic source), otherwise list. - var spawners []*kelos.TaskSpawner - if prefetchedSpawners != nil { - spawners = prefetchedSpawners - } else { - var err error + // Use pre-fetched spawners when available, otherwise list. + spawners := prefetchedSpawners + if spawners == nil { spawners, err = h.getMatchingSpawners(ctx) if err != nil { return false, fmt.Errorf("failed to get matching spawners: %w", err) } } + // SessionSpawners are driven by GitHub webhooks only. var sessionSpawners []*kelos.SessionSpawner - if h.source == GitHubSource { + if parsed.GitHub != nil { if prefetchedSessionSpawners != nil { sessionSpawners = prefetchedSessionSpawners } else { @@ -454,15 +338,9 @@ func (h *WebhookHandler) processWebhook(ctx context.Context, eventType string, p log.Info("Found matching spawners", "taskSpawners", len(spawners), "sessionSpawners", len(sessionSpawners)) - // Lazily enrich the Branch field for issue_comment events on pull - // requests. The GitHub issue_comment payload does not include the PR's - // head ref, so we fetch it from the API once per delivery. - if parsed.GitHub != nil && needsBranchEnrichment(parsed.GitHub) { - h.enrichGitHubIssueCommentBranch(ctx, log, parsed.GitHub) - } + provider.prepare(ctx, h, log, parsed, spawners) tasksCreated := 0 - linearLabelsEnriched := false var taskSpawnerErrors []error for _, spawner := range spawners { @@ -488,19 +366,8 @@ func (h *WebhookHandler) processWebhook(ctx context.Context, eventType string, p } } - // Lazily enrich labels for Linear Comment events. Linear does not - // include issue labels in Comment webhook payloads, so when a - // spawner filters Comments by labels we fetch them from the API. - // Lazily enrich labels once per delivery. We set the flag after the - // call so that a transient API failure does not silently skip label - // filtering for all remaining spawners in this loop. - if parsed.Linear != nil && !linearLabelsEnriched && spawnerNeedsLinearLabels(spawner, parsed.Linear) { - enrichLinearCommentLabels(ctx, spawnerLog, parsed.Linear) - linearLabelsEnriched = true - } - // Check if this webhook matches the spawner's filters - matches, err := h.matchesSpawner(ctx, spawner, eventType, parsed) + matches, err := provider.match(ctx, h, spawner, eventType, parsed) if err != nil { spawnerLog.Error(err, "Failed to check spawner match") continue @@ -514,7 +381,7 @@ func (h *WebhookHandler) processWebhook(ctx context.Context, eventType string, p spawnerLog.Info("Webhook matches spawner filters - creating task") // Create task for this spawner - created, err := h.createTask(ctx, spawner, eventType, parsed, deliveryID) + created, err := h.createTask(ctx, provider, spawner, eventType, parsed, deliveryID) if err != nil { spawnerLog.Error(err, "Failed to create task") taskSpawnerErrors = append(taskSpawnerErrors, fmt.Errorf("spawner %s: %w", spawner.Name, err)) @@ -547,40 +414,27 @@ func (h *WebhookHandler) processWebhook(ctx context.Context, eventType string, p return tasksCreated > 0 || sessionsProcessed > 0, errors.Join(append(taskSpawnerErrors, sessionErrors...)...) } -// getMatchingSpawners returns TaskSpawners that match the webhook source. +// getMatchingSpawners returns TaskSpawners that use the webhook source. +// Spawners bound to a WebhookGateway are skipped: those are served (and +// authenticated) by the gateway path, so the per-source server must not also +// match them, or the Task would be created twice. func (h *WebhookHandler) getMatchingSpawners(ctx context.Context) ([]*kelos.TaskSpawner, error) { + provider, err := providerFor(h.source) + if err != nil { + return nil, err + } var spawnerList kelos.TaskSpawnerList if err := h.client.List(ctx, &spawnerList, &client.ListOptions{}); err != nil { return nil, err } - var matching []*kelos.TaskSpawner + matching := make([]*kelos.TaskSpawner, 0) for i := range spawnerList.Items { spawner := &spawnerList.Items[i] - - // Skip spawners bound to a WebhookGateway: those are served (and - // authenticated) by the gateway path, so the per-source server - // must not also match them, or the Task would be created twice. - switch h.source { - case GitHubSource: - if spawner.Spec.When.GitHubWebhook != nil && spawner.Spec.When.GitHubWebhook.GatewayRef == nil { - matching = append(matching, spawner) - } - case LinearSource: - if spawner.Spec.When.LinearWebhook != nil && spawner.Spec.When.LinearWebhook.GatewayRef == nil { - matching = append(matching, spawner) - } - case GitLabSource: - if spawner.Spec.When.GitLabWebhook != nil && spawner.Spec.When.GitLabWebhook.GatewayRef == nil { - matching = append(matching, spawner) - } - case GenericSource: - if spawner.Spec.When.GenericWebhook != nil && spawner.Spec.When.GenericWebhook.GatewayRef == nil { - matching = append(matching, spawner) - } + if ref, ok := provider.gatewayRef(spawner); ok && ref == nil { + matching = append(matching, spawner) } } - return matching, nil } @@ -606,84 +460,13 @@ func (h *WebhookHandler) getMatchingSessionSpawners(ctx context.Context) ([]*kel return matching, nil } -// matchesSpawner checks if the webhook matches the spawner's configuration. -func (h *WebhookHandler) matchesSpawner(ctx context.Context, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook) (bool, error) { - switch h.source { - case GitHubSource: - return h.matchesGitHubWebhook(ctx, spawner.Spec.When.GitHubWebhook, eventType, parsed.GitHub, func(ctx context.Context, eventData *GitHubEventData) ([]string, error) { - return h.enrichPRChangedFiles(ctx, spawner, eventData) - }) - - case LinearSource: - if spawner.Spec.When.LinearWebhook == nil { - return false, nil - } - return MatchesLinearEvent(spawner.Spec.When.LinearWebhook, parsed.Linear) - - case GitLabSource: - if spawner.Spec.When.GitLabWebhook == nil { - return false, nil - } - return MatchesGitLabEvent(spawner.Spec.When.GitLabWebhook, parsed.GitLab) - - case GenericSource: - if spawner.Spec.When.GenericWebhook == nil { - return false, nil - } - // In per-source mode the URL path segment selects the source, so it must - // match the spawner's declared source. In gateway mode the gatewayRef - // already scoped this spawner, so the source-name check is skipped. - if h.gatewayName == "" && spawner.Spec.When.GenericWebhook.Source != eventType { - return false, nil - } - // Extract fields for this spawner's fieldMapping - if err := parsed.Generic.ExtractFields(spawner.Spec.When.GenericWebhook.FieldMapping); err != nil { - return false, err - } - parsed.ID = parsed.Generic.Fields["id"] - parsed.Title = parsed.Generic.Fields["title"] - matched, err := MatchesGenericFilters(spawner.Spec.When.GenericWebhook.Filters, parsed.Generic.Payload) - if err != nil || !matched { - return false, err - } - excluded, err := MatchesGenericExcludeFilters(spawner.Spec.When.GenericWebhook.ExcludeFilters, parsed.Generic.Payload) - if err != nil { - return false, err - } - return !excluded, nil - - default: - return false, fmt.Errorf("unsupported source: %s", h.source) - } -} - // createTask creates a Task from the webhook event. It returns true when a // new Task was created, and false when the delivery was deduplicated against a // Task this spawner already owns. -func (h *WebhookHandler) createTask(ctx context.Context, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook, deliveryID string) (bool, error) { +func (h *WebhookHandler) createTask(ctx context.Context, provider webhookProvider, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook, deliveryID string) (bool, error) { log := h.log.WithValues("spawner", spawner.Name, "namespace", spawner.Namespace, "eventType", eventType, "deliveryID", deliveryID) - // Extract template variables based on source - var templateVars map[string]interface{} - - switch h.source { - case GitHubSource: - changedFiles := changedFilesForSpawner(spawner.Spec.When.GitHubWebhook, eventType, parsed.GitHub) - templateVars = ExtractGitHubWorkItem(parsed.GitHub, changedFiles) - - case LinearSource: - templateVars = ExtractLinearWorkItem(parsed.Linear) - - case GitLabSource: - templateVars = ExtractGitLabWorkItem(parsed.GitLab) - - case GenericSource: - templateVars = ExtractGenericWorkItem(parsed.Generic) - - default: - return false, fmt.Errorf("unsupported source: %s", h.source) - } - + templateVars := provider.templateVars(spawner, eventType, parsed) log.Info("Extracted template variables", "ID", templateVars["ID"], "Title", templateVars["Title"], "Action", templateVars["Action"]) // Pre-Create deduplication: when a deterministic nameTemplate is configured, @@ -759,71 +542,7 @@ func (h *WebhookHandler) createTask(ctx context.Context, spawner *kelos.TaskSpaw return false, fmt.Errorf("assigning TaskSpawner credential: %w", err) } - // Stamp reporting annotations for GitHub webhook sources when reporting is configured. - if h.source == GitHubSource && parsed.GitHub != nil && parsed.GitHub.Number > 0 && - spawner.Spec.When.GitHubWebhook != nil && - spawner.Spec.When.GitHubWebhook.Reporting != nil { - rep := spawner.Spec.When.GitHubWebhook.Reporting - commentReportingEnabled := rep.Enabled || rep.Comments != nil - if commentReportingEnabled || rep.Checks != nil { - if task.Annotations == nil { - task.Annotations = make(map[string]string) - } - task.Annotations[reporting.AnnotationSourceKind] = webhookSourceKind(eventType, parsed.GitHub) - task.Annotations[reporting.AnnotationSourceNumber] = strconv.Itoa(parsed.GitHub.Number) - task.Annotations[reporting.AnnotationSourceOwner] = parsed.GitHub.RepositoryOwner - task.Annotations[reporting.AnnotationSourceRepo] = parsed.GitHub.RepositoryName - // In gateway mode, record the serving gateway so the reporting - // reconciler can resolve its per-instance credentials and API base URL. - if h.gatewayName != "" { - task.Annotations[reporting.AnnotationWebhookGateway] = h.gatewayName - } - } - if commentReportingEnabled { - task.Annotations[reporting.AnnotationGitHubReporting] = "enabled" - commentMode := kelos.CommentModePerTask - if rep.Comments != nil && rep.Comments.Mode != "" { - commentMode = rep.Comments.Mode - } - task.Annotations[reporting.AnnotationGitHubCommentMode] = string(commentMode) - } - if rep.Checks != nil && parsed.GitHub.HeadSHA != "" { - task.Annotations[reporting.AnnotationGitHubChecks] = "enabled" - task.Annotations[reporting.AnnotationSourceSHA] = parsed.GitHub.HeadSHA - if rep.Checks.Name != "" { - task.Annotations[reporting.AnnotationGitHubCheckName] = rep.Checks.Name - } - } - } - - // Stamp reporting annotations for GitLab webhook sources when notes - // reporting is configured and the event maps to an issue or merge request. - if h.source == GitLabSource && parsed.GitLab != nil && parsed.GitLab.Number > 0 && - spawner.Spec.When.GitLabWebhook != nil && - spawner.Spec.When.GitLabWebhook.Reporting != nil && - spawner.Spec.When.GitLabWebhook.Reporting.Comments != nil { - if task.Annotations == nil { - task.Annotations = make(map[string]string) - } - kind := "issue" - if parsed.GitLab.Kind == "MR" { - kind = reporting.SourceKindMergeRequest - } - task.Annotations[reporting.AnnotationSourceProvider] = reporting.SourceProviderGitLab - task.Annotations[reporting.AnnotationSourceKind] = kind - task.Annotations[reporting.AnnotationSourceNumber] = strconv.Itoa(parsed.GitLab.Number) - task.Annotations[reporting.AnnotationSourceRepo] = parsed.GitLab.Project - task.Annotations[reporting.AnnotationSourceBaseURL] = gitlabInstanceURL(parsed.GitLab.ProjectURL, parsed.GitLab.Project) - if h.gatewayName != "" { - task.Annotations[reporting.AnnotationWebhookGateway] = h.gatewayName - } - task.Annotations[reporting.AnnotationGitHubReporting] = "enabled" - commentMode := kelos.CommentModePerTask - if mode := spawner.Spec.When.GitLabWebhook.Reporting.Comments.Mode; mode != "" { - commentMode = mode - } - task.Annotations[reporting.AnnotationGitHubCommentMode] = string(commentMode) - } + provider.annotate(task, spawner, eventType, parsed, h.gatewayName) if err := h.client.Create(ctx, task); err != nil { // A configured nameTemplate makes Task names deterministic, so a second @@ -1015,25 +734,6 @@ func (h *WebhookHandler) enrichSessionSpawnerPRChangedFiles(ctx context.Context, return fetchSessionSpawnerPRChangedFiles(ctx, h.client, spawner, h.githubTokenResolver, h.githubAPIBaseURL, eventData.RepositoryOwner, eventData.RepositoryName, eventData.Number) } -// getGenericSpawners returns all TaskSpawners that have a generic webhook -// spec. This avoids a redundant second List call during processWebhook. -func (h *WebhookHandler) getGenericSpawners(ctx context.Context) []*kelos.TaskSpawner { - var spawnerList kelos.TaskSpawnerList - if err := h.client.List(ctx, &spawnerList, &client.ListOptions{}); err != nil { - return nil - } - - var spawners []*kelos.TaskSpawner - for i := range spawnerList.Items { - // Skip gateway-bound spawners; they are served by the gateway path. - gw := spawnerList.Items[i].Spec.When.GenericWebhook - if gw != nil && gw.GatewayRef == nil { - spawners = append(spawners, &spawnerList.Items[i]) - } - } - return spawners -} - // webhookSourceKind determines the reporting source kind from a GitHub webhook event. func webhookSourceKind(eventType string, eventData *GitHubEventData) string { switch eventType { diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go index f8ea7711e..d28cf00cc 100644 --- a/internal/webhook/handler_test.go +++ b/internal/webhook/handler_test.go @@ -652,7 +652,7 @@ func TestServeHTTP_StampsStickyCommentReportingAnnotations(t *testing.T) { GitHubWebhook: &kelos.GitHubWebhook{ Events: []string{"issues"}, Reporting: &kelos.GitHubReporting{ - Comments: &kelos.GitHubCommentsReporting{Mode: kelos.CommentModeSticky}, + Comments: &kelos.CommentsReporting{Mode: kelos.CommentModeSticky}, }, }, }, @@ -1489,7 +1489,7 @@ func TestGitLabServeHTTP_StampsReportingAnnotations(t *testing.T) { When: kelos.When{ GitLabWebhook: &kelos.GitLabWebhook{ Events: []string{"note"}, - Reporting: &kelos.GitLabReporting{Comments: &kelos.GitLabCommentsReporting{Mode: kelos.CommentModeSticky}}, + Reporting: &kelos.GitLabReporting{Comments: &kelos.CommentsReporting{Mode: kelos.CommentModeSticky}}, }, }, TaskTemplate: kelos.TaskTemplate{ @@ -2715,7 +2715,7 @@ func TestCreateTask_NameCollisionWithUnrelatedTaskErrors(t *testing.T) { } parsed := &ParsedWebhook{GitHub: eventData} - _, err = handler.createTask(context.Background(), spawner, "pull_request", parsed, "delivery-collide") + _, err = handler.createTask(context.Background(), githubProvider{}, spawner, "pull_request", parsed, "delivery-collide") if err == nil { t.Fatal("expected error when rendered name collides with an unrelated Task, got nil") } diff --git a/internal/webhook/provider.go b/internal/webhook/provider.go new file mode 100644 index 000000000..f01519add --- /dev/null +++ b/internal/webhook/provider.go @@ -0,0 +1,96 @@ +package webhook + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/go-logr/logr" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" + "github.com/kelos-dev/kelos/internal/reporting" +) + +// webhookProvider is the per-source half of webhook handling. WebhookHandler +// owns the delivery pipeline (deduplication, spawner iteration, Task +// creation); a provider owns everything that depends on one source's payload +// format: request verification, delivery identity, parsing, spawner matching, +// template variables, and reporting annotations. +type webhookProvider interface { + // authenticate verifies the request against secret. It returns the event + // type known from headers (refined later by parse) and the delivery ID used + // for deduplication, both also on failure so the rejection can be logged. + // spawners lists this source's spawners and is only consulted by providers + // whose delivery identity depends on spawner configuration. + authenticate(r *http.Request, body, secret []byte, spawners func() []*kelos.TaskSpawner) (eventType, deliveryID string, err error) + // gatewayRef returns the spawner's gateway binding and whether the spawner + // uses this provider at all. + gatewayRef(spawner *kelos.TaskSpawner) (*kelos.GatewayReference, bool) + // parse decodes the payload and returns the event type to use from here on. + parse(log logr.Logger, eventType string, body []byte) (*ParsedWebhook, string, error) + // prepare runs once per delivery before spawners are matched, for payload + // enrichment that needs outbound API calls. + prepare(ctx context.Context, h *WebhookHandler, log logr.Logger, parsed *ParsedWebhook, spawners []*kelos.TaskSpawner) + // match reports whether the spawner's filters accept the event. + match(ctx context.Context, h *WebhookHandler, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook) (bool, error) + // templateVars returns the variables exposed to the spawner's templates. + templateVars(spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook) map[string]interface{} + // annotate stamps reporting annotations on the Task when the spawner + // configures reporting and the event maps to a reportable item. + annotate(task *kelos.Task, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook, gatewayName string) +} + +// webhookProviders is the registry the handlers dispatch through. +var webhookProviders = map[WebhookSource]webhookProvider{ + GitHubSource: githubProvider{}, + LinearSource: linearProvider{}, + GitLabSource: gitlabProvider{}, + GenericSource: genericProvider{}, +} + +func providerFor(source WebhookSource) (webhookProvider, error) { + p, ok := webhookProviders[source] + if !ok { + return nil, fmt.Errorf("unsupported source: %s", source) + } + return p, nil +} + +// httpError is an authentication failure that carries its own HTTP response, +// used when a request is malformed rather than unauthorized. +type httpError struct { + status int + message string +} + +func (e *httpError) Error() string { return e.message } + +// reportingAnnotations returns the annotations every provider stamps when +// comment reporting is configured: the reporting target and the comment mode. +// Providers add their own addressing (owner/repo, project) on top. +func reportingAnnotations(tracker kelos.TrackerSource, kind string, number int, gatewayName string) map[string]string { + annotations := map[string]string{ + reporting.AnnotationSourceKind: kind, + reporting.AnnotationSourceNumber: strconv.Itoa(number), + } + // In gateway mode, record the serving gateway so the reporting reconciler + // can resolve its per-instance credentials and API base URL. + if gatewayName != "" { + annotations[reporting.AnnotationWebhookGateway] = gatewayName + } + if tracker.Comments != nil { + annotations[reporting.AnnotationGitHubReporting] = "enabled" + annotations[reporting.AnnotationGitHubCommentMode] = string(tracker.CommentMode()) + } + return annotations +} + +func addAnnotations(task *kelos.Task, annotations map[string]string) { + if task.Annotations == nil { + task.Annotations = make(map[string]string, len(annotations)) + } + for k, v := range annotations { + task.Annotations[k] = v + } +} diff --git a/internal/webhook/provider_generic.go b/internal/webhook/provider_generic.go new file mode 100644 index 000000000..7d4989f2e --- /dev/null +++ b/internal/webhook/provider_generic.go @@ -0,0 +1,77 @@ +package webhook + +import ( + "context" + "net/http" + + "github.com/go-logr/logr" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +// genericProvider accepts arbitrary JSON without verification; the URL path +// selects the source name. Restrict access at the network layer. +type genericProvider struct{} + +// authenticate derives the delivery ID from the "id" fieldMapping of the +// source's spawners, so retries of one logical event deduplicate even when +// the raw JSON encoding differs. +func (genericProvider) authenticate(r *http.Request, body, _ []byte, spawners func() []*kelos.TaskSpawner) (string, string, error) { + sourceName, err := extractSourceFromPath(r.URL.Path) + if err != nil { + return "", "", &httpError{status: http.StatusBadRequest, message: err.Error()} + } + return sourceName, extractGenericDeliveryID(sourceName, body, spawners()), nil +} + +func (genericProvider) gatewayRef(spawner *kelos.TaskSpawner) (*kelos.GatewayReference, bool) { + if spawner.Spec.When.GenericWebhook == nil { + return nil, false + } + return spawner.Spec.When.GenericWebhook.GatewayRef, true +} + +func (genericProvider) parse(_ logr.Logger, eventType string, body []byte) (*ParsedWebhook, string, error) { + eventData, err := ParseGenericWebhook(body) + if err != nil { + return nil, eventType, err + } + // ID and Title come from each spawner's fieldMapping during match. + return &ParsedWebhook{Generic: eventData}, eventType, nil +} + +func (genericProvider) prepare(context.Context, *WebhookHandler, logr.Logger, *ParsedWebhook, []*kelos.TaskSpawner) { +} + +func (genericProvider) match(_ context.Context, h *WebhookHandler, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook) (bool, error) { + generic := spawner.Spec.When.GenericWebhook + if generic == nil { + return false, nil + } + // In per-source mode the URL path segment selects the source, so it must + // match the spawner's declared source. In gateway mode the gatewayRef + // already scoped this spawner, so the source-name check is skipped. + if h.gatewayName == "" && generic.Source != eventType { + return false, nil + } + if err := parsed.Generic.ExtractFields(generic.FieldMapping); err != nil { + return false, err + } + parsed.ID = parsed.Generic.Fields["id"] + parsed.Title = parsed.Generic.Fields["title"] + matched, err := MatchesGenericFilters(generic.Filters, parsed.Generic.Payload) + if err != nil || !matched { + return false, err + } + excluded, err := MatchesGenericExcludeFilters(generic.ExcludeFilters, parsed.Generic.Payload) + if err != nil { + return false, err + } + return !excluded, nil +} + +func (genericProvider) templateVars(_ *kelos.TaskSpawner, _ string, parsed *ParsedWebhook) map[string]interface{} { + return ExtractGenericWorkItem(parsed.Generic) +} + +func (genericProvider) annotate(*kelos.Task, *kelos.TaskSpawner, string, *ParsedWebhook, string) {} diff --git a/internal/webhook/provider_github.go b/internal/webhook/provider_github.go new file mode 100644 index 000000000..3cc9e428c --- /dev/null +++ b/internal/webhook/provider_github.go @@ -0,0 +1,73 @@ +package webhook + +import ( + "context" + "net/http" + + "github.com/go-logr/logr" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" + "github.com/kelos-dev/kelos/internal/reporting" +) + +type githubProvider struct{} + +func (githubProvider) authenticate(r *http.Request, body, secret []byte, _ func() []*kelos.TaskSpawner) (string, string, error) { + eventType := r.Header.Get(GitHubEventHeader) + deliveryID := r.Header.Get(GitHubDeliveryHeader) + if deliveryID == "" { + deliveryID = githubDeliveryID(body) + } + return eventType, deliveryID, ValidateGitHubSignature(body, r.Header.Get(GitHubSignatureHeader), secret) +} + +func (githubProvider) gatewayRef(spawner *kelos.TaskSpawner) (*kelos.GatewayReference, bool) { + if spawner.Spec.When.GitHubWebhook == nil { + return nil, false + } + return spawner.Spec.When.GitHubWebhook.GatewayRef, true +} + +func (githubProvider) parse(_ logr.Logger, eventType string, body []byte) (*ParsedWebhook, string, error) { + eventData, err := ParseGitHubWebhook(eventType, body) + if err != nil { + return nil, eventType, err + } + return &ParsedWebhook{GitHub: eventData, ID: eventData.ID, Title: eventData.Title}, eventType, nil +} + +// prepare fills the Branch of issue_comment events on pull requests: the +// payload does not include the PR head ref, so it is fetched once per delivery. +func (githubProvider) prepare(ctx context.Context, h *WebhookHandler, log logr.Logger, parsed *ParsedWebhook, _ []*kelos.TaskSpawner) { + if needsBranchEnrichment(parsed.GitHub) { + h.enrichGitHubIssueCommentBranch(ctx, log, parsed.GitHub) + } +} + +func (githubProvider) match(ctx context.Context, h *WebhookHandler, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook) (bool, error) { + return h.matchesGitHubWebhook(ctx, spawner.Spec.When.GitHubWebhook, eventType, parsed.GitHub, func(ctx context.Context, eventData *GitHubEventData) ([]string, error) { + return h.enrichPRChangedFiles(ctx, spawner, eventData) + }) +} + +func (githubProvider) templateVars(spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook) map[string]interface{} { + return ExtractGitHubWorkItem(parsed.GitHub, changedFilesForSpawner(spawner.Spec.When.GitHubWebhook, eventType, parsed.GitHub)) +} + +func (githubProvider) annotate(task *kelos.Task, spawner *kelos.TaskSpawner, eventType string, parsed *ParsedWebhook, gatewayName string) { + tracker, _ := spawner.Spec.When.Tracker() + if parsed.GitHub == nil || parsed.GitHub.Number == 0 || (tracker.Comments == nil && tracker.Checks == nil) { + return + } + annotations := reportingAnnotations(tracker, webhookSourceKind(eventType, parsed.GitHub), parsed.GitHub.Number, gatewayName) + annotations[reporting.AnnotationSourceOwner] = parsed.GitHub.RepositoryOwner + annotations[reporting.AnnotationSourceRepo] = parsed.GitHub.RepositoryName + if tracker.Checks != nil && parsed.GitHub.HeadSHA != "" { + annotations[reporting.AnnotationGitHubChecks] = "enabled" + annotations[reporting.AnnotationSourceSHA] = parsed.GitHub.HeadSHA + if tracker.Checks.Name != "" { + annotations[reporting.AnnotationGitHubCheckName] = tracker.Checks.Name + } + } + addAnnotations(task, annotations) +} diff --git a/internal/webhook/provider_gitlab.go b/internal/webhook/provider_gitlab.go new file mode 100644 index 000000000..8f3bee0d3 --- /dev/null +++ b/internal/webhook/provider_gitlab.go @@ -0,0 +1,72 @@ +package webhook + +import ( + "context" + "net/http" + + "github.com/go-logr/logr" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" + "github.com/kelos-dev/kelos/internal/reporting" +) + +type gitlabProvider struct{} + +// authenticate reports the source name as event type: the X-Gitlab-Event +// header carries a display name ("Merge Request Hook"), and the payload +// object_kind that parse extracts is the canonical event type. +func (gitlabProvider) authenticate(r *http.Request, body, secret []byte, _ func() []*kelos.TaskSpawner) (string, string, error) { + return string(GitLabSource), gitlabRequestDeliveryID(r, body), ValidateGitLabToken(r.Header.Get(GitLabTokenHeader), secret) +} + +func (gitlabProvider) gatewayRef(spawner *kelos.TaskSpawner) (*kelos.GatewayReference, bool) { + if spawner.Spec.When.GitLabWebhook == nil { + return nil, false + } + return spawner.Spec.When.GitLabWebhook.GatewayRef, true +} + +func (gitlabProvider) parse(log logr.Logger, eventType string, body []byte) (*ParsedWebhook, string, error) { + eventData, err := ParseGitLabWebhook(body) + if err != nil { + return nil, eventType, err + } + if eventData.Event != "" { + eventType = eventData.Event + } else { + log.Info("GitLab webhook payload has no 'object_kind' field, will not match any Events filter") + } + return &ParsedWebhook{GitLab: eventData, ID: eventData.ID, Title: eventData.Title}, eventType, nil +} + +func (gitlabProvider) prepare(context.Context, *WebhookHandler, logr.Logger, *ParsedWebhook, []*kelos.TaskSpawner) { +} + +func (gitlabProvider) match(_ context.Context, _ *WebhookHandler, spawner *kelos.TaskSpawner, _ string, parsed *ParsedWebhook) (bool, error) { + if spawner.Spec.When.GitLabWebhook == nil { + return false, nil + } + return MatchesGitLabEvent(spawner.Spec.When.GitLabWebhook, parsed.GitLab) +} + +func (gitlabProvider) templateVars(_ *kelos.TaskSpawner, _ string, parsed *ParsedWebhook) map[string]interface{} { + return ExtractGitLabWorkItem(parsed.GitLab) +} + +// annotate stamps note reporting for events that map to an issue or merge +// request. +func (gitlabProvider) annotate(task *kelos.Task, spawner *kelos.TaskSpawner, _ string, parsed *ParsedWebhook, gatewayName string) { + tracker, _ := spawner.Spec.When.Tracker() + if parsed.GitLab == nil || parsed.GitLab.Number == 0 || tracker.Comments == nil { + return + } + kind := "issue" + if parsed.GitLab.Kind == "MR" { + kind = reporting.SourceKindMergeRequest + } + annotations := reportingAnnotations(tracker, kind, parsed.GitLab.Number, gatewayName) + annotations[reporting.AnnotationSourceProvider] = reporting.SourceProviderGitLab + annotations[reporting.AnnotationSourceRepo] = parsed.GitLab.Project + annotations[reporting.AnnotationSourceBaseURL] = gitlabInstanceURL(parsed.GitLab.ProjectURL, parsed.GitLab.Project) + addAnnotations(task, annotations) +} diff --git a/internal/webhook/provider_linear.go b/internal/webhook/provider_linear.go new file mode 100644 index 000000000..898f3b8e5 --- /dev/null +++ b/internal/webhook/provider_linear.go @@ -0,0 +1,69 @@ +package webhook + +import ( + "context" + "net/http" + "strings" + + "github.com/go-logr/logr" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +type linearProvider struct{} + +// authenticate reports the source name as event type; Linear sends the +// resource type in the payload only, which parse applies. +func (linearProvider) authenticate(r *http.Request, body, secret []byte, _ func() []*kelos.TaskSpawner) (string, string, error) { + deliveryID := r.Header.Get(LinearDeliveryHeader) + if deliveryID == "" { + deliveryID = linearDeliveryID(body) + } + return string(LinearSource), deliveryID, ValidateLinearSignature(body, r.Header.Get(LinearSignatureHeader), secret) +} + +func (linearProvider) gatewayRef(spawner *kelos.TaskSpawner) (*kelos.GatewayReference, bool) { + if spawner.Spec.When.LinearWebhook == nil { + return nil, false + } + return spawner.Spec.When.LinearWebhook.GatewayRef, true +} + +func (linearProvider) parse(log logr.Logger, eventType string, body []byte) (*ParsedWebhook, string, error) { + eventData, err := ParseLinearWebhook(body) + if err != nil { + return nil, eventType, err + } + // The resource type (e.g. "Issue", "Comment") makes Task names + // distinguishable. + if eventData.Type != "" { + eventType = strings.ToLower(eventData.Type) + } else { + log.Info("Linear webhook payload has no 'type' field, will not match any Types filter") + } + return &ParsedWebhook{Linear: eventData, ID: eventData.ID, Title: eventData.Title}, eventType, nil +} + +// prepare fetches issue labels for Comment events when any spawner filters +// them by label, because Linear omits labels from Comment payloads. +func (linearProvider) prepare(ctx context.Context, _ *WebhookHandler, log logr.Logger, parsed *ParsedWebhook, spawners []*kelos.TaskSpawner) { + for _, spawner := range spawners { + if spawnerNeedsLinearLabels(spawner, parsed.Linear) { + enrichLinearCommentLabels(ctx, log, parsed.Linear) + return + } + } +} + +func (linearProvider) match(_ context.Context, _ *WebhookHandler, spawner *kelos.TaskSpawner, _ string, parsed *ParsedWebhook) (bool, error) { + if spawner.Spec.When.LinearWebhook == nil { + return false, nil + } + return MatchesLinearEvent(spawner.Spec.When.LinearWebhook, parsed.Linear) +} + +func (linearProvider) templateVars(_ *kelos.TaskSpawner, _ string, parsed *ParsedWebhook) map[string]interface{} { + return ExtractLinearWorkItem(parsed.Linear) +} + +func (linearProvider) annotate(*kelos.Task, *kelos.TaskSpawner, string, *ParsedWebhook, string) {} diff --git a/internal/webhook/provider_test.go b/internal/webhook/provider_test.go new file mode 100644 index 000000000..82f5f1860 --- /dev/null +++ b/internal/webhook/provider_test.go @@ -0,0 +1,40 @@ +package webhook + +import ( + "testing" + + kelos "github.com/kelos-dev/kelos/api/v1alpha2" +) + +func TestProviderForCoversEverySource(t *testing.T) { + for _, source := range []WebhookSource{GitHubSource, LinearSource, GitLabSource, GenericSource} { + if _, err := providerFor(source); err != nil { + t.Errorf("providerFor(%s) error = %v", source, err) + } + } + if _, err := providerFor("bitbucket"); err == nil { + t.Error("providerFor(unknown) must fail so an unregistered source cannot be served") + } +} + +func TestProviderGatewayRefSelectsOwnSpec(t *testing.T) { + ref := &kelos.GatewayReference{Name: "gw"} + spawners := map[WebhookSource]*kelos.TaskSpawner{ + GitHubSource: {Spec: kelos.TaskSpawnerSpec{When: kelos.When{GitHubWebhook: &kelos.GitHubWebhook{GatewayRef: ref}}}}, + LinearSource: {Spec: kelos.TaskSpawnerSpec{When: kelos.When{LinearWebhook: &kelos.LinearWebhook{GatewayRef: ref}}}}, + GitLabSource: {Spec: kelos.TaskSpawnerSpec{When: kelos.When{GitLabWebhook: &kelos.GitLabWebhook{GatewayRef: ref}}}}, + GenericSource: {Spec: kelos.TaskSpawnerSpec{When: kelos.When{GenericWebhook: &kelos.GenericWebhook{GatewayRef: ref}}}}, + } + for source, provider := range webhookProviders { + for spawnerSource, spawner := range spawners { + got, ok := provider.gatewayRef(spawner) + if spawnerSource == source { + if !ok || got != ref { + t.Errorf("%s provider must claim its own spawner with its gatewayRef, got (%v, %v)", source, got, ok) + } + } else if ok { + t.Errorf("%s provider must not claim a %s spawner", source, spawnerSource) + } + } + } +} diff --git a/test/integration/taskspawner_test.go b/test/integration/taskspawner_test.go index b8a116234..d2b31c3d9 100644 --- a/test/integration/taskspawner_test.go +++ b/test/integration/taskspawner_test.go @@ -2632,7 +2632,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelos.When{GitHubIssues: &kelos.GitHubIssues{ Repo: "kelos-dev/kelos", Reporting: &kelos.GitHubReporting{ - Comments: &kelos.GitHubCommentsReporting{}, + Comments: &kelos.CommentsReporting{}, }, }}, TaskTemplate: kelos.TaskTemplate{ @@ -2664,7 +2664,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelos.When{GitHubIssues: &kelos.GitHubIssues{ Repo: "kelos-dev/kelos", Reporting: &kelos.GitHubReporting{ - Comments: &kelos.GitHubCommentsReporting{Mode: "Unsupported"}, + Comments: &kelos.CommentsReporting{Mode: "Unsupported"}, }, }}, TaskTemplate: kelos.TaskTemplate{ From 224e4ae919fd5cb7589d2e07fa3c88db39a0fd6a Mon Sep 17 00:00:00 2001 From: Jan Soukup Date: Wed, 2 Sep 2026 23:26:27 +0200 Subject: [PATCH 6/7] ref(naming): strip github naming from shared sources to better support gitlab (and bitbucket) with unified approach --- cmd/kelos-spawner/main.go | 8 +- cmd/kelos-spawner/main_test.go | 86 +++--- cmd/kelos-webhook-server/reporting.go | 21 +- cmd/kelos-webhook-server/reporting_test.go | 52 ++-- internal/reporting/gitlab.go | 13 +- internal/reporting/gitlab_test.go | 14 +- internal/reporting/watcher.go | 108 +++++--- internal/reporting/watcher_test.go | 298 ++++++++++++--------- internal/webhook/handler_test.go | 60 ++--- internal/webhook/provider.go | 4 +- internal/webhook/provider_github.go | 4 +- 11 files changed, 373 insertions(+), 295 deletions(-) diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index e23db7466..d44819dcd 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -646,17 +646,17 @@ func sourceAnnotations(ts *kelos.TaskSpawner, item source.WorkItem) map[string]s } if tracker.Comments != nil { - annotations[reporting.AnnotationGitHubReporting] = "enabled" - annotations[reporting.AnnotationGitHubCommentMode] = string(tracker.CommentMode()) + annotations[reporting.AnnotationCommentReporting] = "enabled" + annotations[reporting.AnnotationCommentMode] = string(tracker.CommentMode()) } if tracker.Checks != nil { - annotations[reporting.AnnotationGitHubChecks] = "enabled" + annotations[reporting.AnnotationCheckReporting] = "enabled" if item.HeadSHA != "" { annotations[reporting.AnnotationSourceSHA] = item.HeadSHA } if tracker.Checks.Name != "" { - annotations[reporting.AnnotationGitHubCheckName] = tracker.Checks.Name + annotations[reporting.AnnotationCheckName] = tracker.Checks.Name } } diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index f24bc1f69..472421da0 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -2041,7 +2041,7 @@ func TestSourceAnnotations_GitHubIssues(t *testing.T) { if annotations[reporting.AnnotationSourceNumber] != "42" { t.Errorf("Expected source-number '42', got %q", annotations[reporting.AnnotationSourceNumber]) } - if _, ok := annotations[reporting.AnnotationGitHubReporting]; ok { + if _, ok := annotations[reporting.AnnotationCommentReporting]; ok { t.Error("Expected no github-reporting annotation when reporting is not enabled") } } @@ -2090,18 +2090,18 @@ func TestSourceAnnotations_GitLab(t *testing.T) { if issue[reporting.AnnotationSourceKind] != "issue" || issue[reporting.AnnotationSourceNumber] != "42" { t.Errorf("unexpected issue annotations: %v", issue) } - if issue[reporting.AnnotationGitHubReporting] != "enabled" { + if issue[reporting.AnnotationCommentReporting] != "enabled" { t.Errorf("Expected reporting enabled annotation, got %v", issue) } - if issue[reporting.AnnotationGitHubCommentMode] != string(kelos.CommentModeSticky) { - t.Errorf("Expected sticky comment mode, got %q", issue[reporting.AnnotationGitHubCommentMode]) + if issue[reporting.AnnotationCommentMode] != string(kelos.CommentModeSticky) { + t.Errorf("Expected sticky comment mode, got %q", issue[reporting.AnnotationCommentMode]) } mr := sourceAnnotations(ts, source.WorkItem{ID: "mr-7", Number: 7, Kind: "MR", HeadSHA: "abc"}) if mr[reporting.AnnotationSourceKind] != reporting.SourceKindMergeRequest || mr[reporting.AnnotationSourceNumber] != "7" { t.Errorf("unexpected merge request annotations: %v", mr) } - if _, ok := mr[reporting.AnnotationGitHubChecks]; ok { + if _, ok := mr[reporting.AnnotationCheckReporting]; ok { t.Error("Expected no checks annotation for GitLab source") } } @@ -2116,7 +2116,7 @@ func TestSourceAnnotations_GitLabReportingDisabled(t *testing.T) { if annotations[reporting.AnnotationSourceNumber] != "1" { t.Errorf("Expected source annotations even without reporting, got %v", annotations) } - if _, ok := annotations[reporting.AnnotationGitHubReporting]; ok { + if _, ok := annotations[reporting.AnnotationCommentReporting]; ok { t.Error("Expected no reporting annotation when comments reporting is not configured") } } @@ -2141,8 +2141,8 @@ func TestSourceAnnotations_ReportingEnabled(t *testing.T) { } annotations := sourceAnnotations(ts, item) - if annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationGitHubReporting]) + if annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationCommentReporting]) } } @@ -2166,8 +2166,8 @@ func TestSourceAnnotations_ReportingEnabledPR(t *testing.T) { } annotations := sourceAnnotations(ts, item) - if annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationGitHubReporting]) + if annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationCommentReporting]) } } @@ -2217,8 +2217,8 @@ func TestRunCycleWithSource_AnnotationsStamped(t *testing.T) { if task.Annotations[reporting.AnnotationSourceNumber] != "42" { t.Errorf("Expected source-number '42', got %q", task.Annotations[reporting.AnnotationSourceNumber]) } - if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) + if task.Annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationCommentReporting]) } } @@ -2267,10 +2267,10 @@ func TestRunCycleWithSource_TaskTemplateMetadataReservedAnnotationsPrecedence(t ts := newTaskSpawner("spawner", "default", nil) ts.Spec.TaskTemplate.Metadata = &kelos.TaskTemplateMetadata{ Annotations: map[string]string{ - reporting.AnnotationSourceKind: "wrong", - reporting.AnnotationSourceNumber: "999", - reporting.AnnotationGitHubReporting: "disabled", - "kelos.dev/preserved-custom": "from-template", + reporting.AnnotationSourceKind: "wrong", + reporting.AnnotationSourceNumber: "999", + reporting.AnnotationCommentReporting: "disabled", + "kelos.dev/preserved-custom": "from-template", }, } ts.Spec.When.GitHubIssues.Reporting = &kelos.GitHubReporting{Enabled: true} @@ -2297,8 +2297,8 @@ func TestRunCycleWithSource_TaskTemplateMetadataReservedAnnotationsPrecedence(t if task.Annotations[reporting.AnnotationSourceNumber] != "42" { t.Errorf("Source should win for %s, got %q", reporting.AnnotationSourceNumber, task.Annotations[reporting.AnnotationSourceNumber]) } - if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Source should win for %s, got %q", reporting.AnnotationGitHubReporting, task.Annotations[reporting.AnnotationGitHubReporting]) + if task.Annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Source should win for %s, got %q", reporting.AnnotationCommentReporting, task.Annotations[reporting.AnnotationCommentReporting]) } if task.Annotations["kelos.dev/preserved-custom"] != "from-template" { t.Errorf(`Non-conflicting template annotation should be kept, got %q`, task.Annotations["kelos.dev/preserved-custom"]) @@ -2435,7 +2435,7 @@ func TestReportingEnabled_GitLab(t *testing.T) { if !reportingEnabled(enabled) { t.Error("Expected reporting to be enabled for GitLab comments reporting") } - if got := sourceAnnotations(enabled, source.WorkItem{Kind: "MR", Number: 1})[reporting.AnnotationGitHubCommentMode]; got != string(kelos.CommentModePerTask) { + if got := sourceAnnotations(enabled, source.WorkItem{Kind: "MR", Number: 1})[reporting.AnnotationCommentMode]; got != string(kelos.CommentModePerTask) { t.Errorf("comment mode annotation = %q, want PerTask default", got) } } @@ -2519,14 +2519,14 @@ func TestSourceAnnotations_ChecksEnabled(t *testing.T) { } annotations := sourceAnnotations(ts, item) - if annotations[reporting.AnnotationGitHubChecks] != "enabled" { - t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationGitHubChecks]) + if annotations[reporting.AnnotationCheckReporting] != "enabled" { + t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationCheckReporting]) } 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]) + if annotations[reporting.AnnotationCheckName] != "My Custom Check" { + t.Errorf("Expected check name 'My Custom Check', got %q", annotations[reporting.AnnotationCheckName]) } } @@ -2552,11 +2552,11 @@ func TestSourceAnnotations_ChecksAndCommentsEnabled(t *testing.T) { } annotations := sourceAnnotations(ts, item) - if annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationGitHubReporting]) + if annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationCommentReporting]) } - if annotations[reporting.AnnotationGitHubChecks] != "enabled" { - t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationGitHubChecks]) + if annotations[reporting.AnnotationCheckReporting] != "enabled" { + t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationCheckReporting]) } } @@ -2574,11 +2574,11 @@ func TestSourceAnnotations_StickyComments(t *testing.T) { } annotations := sourceAnnotations(ts, source.WorkItem{Number: 5, Kind: "PR"}) - if annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationGitHubReporting]) + if annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", annotations[reporting.AnnotationCommentReporting]) } - if annotations[reporting.AnnotationGitHubCommentMode] != string(kelos.CommentModeSticky) { - t.Errorf("Expected Sticky comment mode, got %q", annotations[reporting.AnnotationGitHubCommentMode]) + if annotations[reporting.AnnotationCommentMode] != string(kelos.CommentModeSticky) { + t.Errorf("Expected Sticky comment mode, got %q", annotations[reporting.AnnotationCommentMode]) } } @@ -2601,8 +2601,8 @@ func TestSourceAnnotations_ChecksNoSHA(t *testing.T) { } annotations := sourceAnnotations(ts, item) - if annotations[reporting.AnnotationGitHubChecks] != "enabled" { - t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationGitHubChecks]) + if annotations[reporting.AnnotationCheckReporting] != "enabled" { + t.Errorf("Expected github-checks 'enabled', got %q", annotations[reporting.AnnotationCheckReporting]) } if _, ok := annotations[reporting.AnnotationSourceSHA]; ok { t.Error("Expected no source-sha annotation when HeadSHA is empty") @@ -2628,7 +2628,7 @@ func TestSourceAnnotations_ChecksNoCustomName(t *testing.T) { } annotations := sourceAnnotations(ts, item) - if _, ok := annotations[reporting.AnnotationGitHubCheckName]; ok { + if _, ok := annotations[reporting.AnnotationCheckName]; ok { t.Error("Expected no check-name annotation when CheckName is not configured") } } @@ -2646,9 +2646,9 @@ func TestRunReportingCycle_ReportsForAnnotatedTasks(t *testing.T) { "kelos.dev/taskspawner": "spawner", }, Annotations: map[string]string{ - reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationSourceNumber: "42", - reporting.AnnotationSourceKind: "issue", + reporting.AnnotationCommentReporting: "enabled", + reporting.AnnotationSourceNumber: "42", + reporting.AnnotationSourceKind: "issue", }, }, Spec: kelos.TaskSpec{ @@ -2692,10 +2692,10 @@ func TestRunReportingCycle_ReportsForAnnotatedTasks(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(&task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[reporting.AnnotationGitHubReportPhase] != "accepted" { - t.Errorf("Expected report phase 'accepted', got %q", updated.Annotations[reporting.AnnotationGitHubReportPhase]) + if updated.Annotations[reporting.AnnotationCommentReportPhase] != "accepted" { + t.Errorf("Expected report phase 'accepted', got %q", updated.Annotations[reporting.AnnotationCommentReportPhase]) } - if updated.Annotations[reporting.AnnotationGitHubCommentID] == "" { + if updated.Annotations[reporting.AnnotationCommentID] == "" { t.Error("Expected comment ID to be set") } } @@ -2780,9 +2780,9 @@ func TestRunOnce_UsesTokenResolverForReporting(t *testing.T) { task := newTask("spawner-1", "default", "spawner", kelos.TaskPhasePending) task.Annotations = map[string]string{ - reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationSourceNumber: "42", - reporting.AnnotationSourceKind: "issue", + reporting.AnnotationCommentReporting: "enabled", + reporting.AnnotationSourceNumber: "42", + reporting.AnnotationSourceKind: "issue", } cl, key := setupTest(t, ts, task) diff --git a/cmd/kelos-webhook-server/reporting.go b/cmd/kelos-webhook-server/reporting.go index 7371d3633..593c347b4 100644 --- a/cmd/kelos-webhook-server/reporting.go +++ b/cmd/kelos-webhook-server/reporting.go @@ -48,7 +48,7 @@ const defaultGitLabBaseURL = "https://gitlab.com" type reportingReconciler struct { client.Client config reportingConfig - // cache survives across reconciles to backstop the AnnotationGitHubCommentID + // cache survives across reconciles to backstop the AnnotationCommentID // annotation on fast Pending→Succeeded transitions where the annotation // Update has not yet propagated to the controller-runtime cache. cache *reporting.ReportStateCache @@ -62,9 +62,7 @@ func (r *reportingReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, client.IgnoreNotFound(err) } - if task.Annotations == nil || - (task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" && - task.Annotations[reporting.AnnotationGitHubChecks] != "enabled") { + if !reportingEnabled(&task) { return ctrl.Result{}, nil } @@ -120,7 +118,7 @@ func (r *reportingReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( Cache: r.cache, } - if task.Annotations[reporting.AnnotationGitHubChecks] == "enabled" { + if reporting.ReadAnnotation(task.Annotations, reporting.AnnotationCheckReporting) == "enabled" { reporter.ChecksReporter = &reporting.ChecksReporter{ Owner: owner, Repo: repo, @@ -158,9 +156,9 @@ func (r *reportingReconciler) reportGitLab(ctx context.Context, task *kelos.Task reporter := &reporting.TaskReporter{ Client: r.Client, Reporter: &reporting.GitLabReporter{ - BaseURL: baseURL, - Project: project, - Token: token, + BaseURL: baseURL, + Project: project, + TokenFunc: reporting.StaticToken(token), }, Cache: r.cache, } @@ -263,8 +261,8 @@ func (r *reportingReconciler) SetupWithManager(mgr ctrl.Manager) error { } // reportingAnnotationPredicate filters Task events down to ones the reporter -// actually cares about: only Tasks carrying the github-reporting annotation, -// and only on phase transitions. Status sub-resource updates do not bump +// actually cares about: only Tasks carrying a reporting annotation, and only +// on phase transitions. Status sub-resource updates do not bump // metadata.generation, so GenerationChangedPredicate alone would miss them. type reportingAnnotationPredicate struct{} @@ -291,5 +289,6 @@ func reportingEnabled(obj client.Object) bool { return false } a := obj.GetAnnotations() - return a[reporting.AnnotationGitHubReporting] == "enabled" || a[reporting.AnnotationGitHubChecks] == "enabled" + return reporting.ReadAnnotation(a, reporting.AnnotationCommentReporting) == "enabled" || + reporting.ReadAnnotation(a, reporting.AnnotationCheckReporting) == "enabled" } diff --git a/cmd/kelos-webhook-server/reporting_test.go b/cmd/kelos-webhook-server/reporting_test.go index 1a075a580..4c8e7edba 100644 --- a/cmd/kelos-webhook-server/reporting_test.go +++ b/cmd/kelos-webhook-server/reporting_test.go @@ -70,11 +70,11 @@ func TestReportingReconcilerSkipsTasksOwnedByOtherServerMode(t *testing.T) { Name: "task", Namespace: "default", Annotations: map[string]string{ - reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationSourceOwner: "owner", - reporting.AnnotationSourceRepo: "repo", - reporting.AnnotationWebhookGateway: tt.gatewayName, - reporting.AnnotationSourceProvider: tt.provider, + reporting.AnnotationCommentReporting: "enabled", + reporting.AnnotationSourceOwner: "owner", + reporting.AnnotationSourceRepo: "repo", + reporting.AnnotationWebhookGateway: tt.gatewayName, + reporting.AnnotationSourceProvider: tt.provider, }, }, } @@ -198,12 +198,12 @@ func TestReportingReconcilerPostsGitLabNote(t *testing.T) { gotPath, gotToken = "", "" mu.Unlock() annotations := map[string]string{ - reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationGitHubCommentMode: string(kelos.CommentModePerTask), - reporting.AnnotationSourceProvider: reporting.SourceProviderGitLab, - reporting.AnnotationSourceKind: reporting.SourceKindMergeRequest, - reporting.AnnotationSourceNumber: "7", - reporting.AnnotationSourceRepo: "group/sub/repo", + reporting.AnnotationCommentReporting: "enabled", + reporting.AnnotationCommentMode: string(kelos.CommentModePerTask), + reporting.AnnotationSourceProvider: reporting.SourceProviderGitLab, + reporting.AnnotationSourceKind: reporting.SourceKindMergeRequest, + reporting.AnnotationSourceNumber: "7", + reporting.AnnotationSourceRepo: "group/sub/repo", } for k, v := range tt.annotations { annotations[k] = v @@ -238,8 +238,8 @@ func TestReportingReconcilerPostsGitLabNote(t *testing.T) { if err := reconciler.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "task"}, &updated); err != nil { t.Fatal(err) } - if updated.Annotations[reporting.AnnotationGitHubCommentID] != "77" { - t.Errorf("expected note id persisted, got %q", updated.Annotations[reporting.AnnotationGitHubCommentID]) + if updated.Annotations[reporting.AnnotationCommentID] != "77" { + t.Errorf("expected note id persisted, got %q", updated.Annotations[reporting.AnnotationCommentID]) } }) } @@ -345,12 +345,12 @@ func TestReportingReconcilerUsesGatewayGitHubAppIdentityForStickyComments(t *tes UID: types.UID("task-uid"), Labels: map[string]string{"kelos.dev/taskspawner": "reviewer"}, Annotations: map[string]string{ - reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), - reporting.AnnotationSourceOwner: "owner", - reporting.AnnotationSourceRepo: "repo", - reporting.AnnotationSourceNumber: "42", - reporting.AnnotationWebhookGateway: gateway.Name, + reporting.AnnotationCommentReporting: "enabled", + reporting.AnnotationCommentMode: string(kelos.CommentModeSticky), + reporting.AnnotationSourceOwner: "owner", + reporting.AnnotationSourceRepo: "repo", + reporting.AnnotationSourceNumber: "42", + reporting.AnnotationWebhookGateway: gateway.Name, }, }, Status: kelos.TaskStatus{Phase: kelos.TaskPhasePending}, @@ -447,10 +447,10 @@ func TestReportingAnnotationPredicate_Create(t *testing.T) { annotations map[string]string want bool }{ - {name: "reporting enabled", annotations: map[string]string{reporting.AnnotationGitHubReporting: "enabled"}, want: true}, - {name: "checks enabled", annotations: map[string]string{reporting.AnnotationGitHubChecks: "enabled"}, want: true}, - {name: "both enabled", annotations: map[string]string{reporting.AnnotationGitHubReporting: "enabled", reporting.AnnotationGitHubChecks: "enabled"}, want: true}, - {name: "reporting disabled value", annotations: map[string]string{reporting.AnnotationGitHubReporting: "disabled"}, want: false}, + {name: "reporting enabled", annotations: map[string]string{reporting.AnnotationCommentReporting: "enabled"}, want: true}, + {name: "checks enabled", annotations: map[string]string{reporting.AnnotationCheckReporting: "enabled"}, want: true}, + {name: "both enabled", annotations: map[string]string{reporting.AnnotationCommentReporting: "enabled", reporting.AnnotationCheckReporting: "enabled"}, want: true}, + {name: "reporting disabled value", annotations: map[string]string{reporting.AnnotationCommentReporting: "disabled"}, want: false}, {name: "missing annotation", annotations: nil, want: false}, {name: "unrelated annotations only", annotations: map[string]string{"other": "value"}, want: false}, } @@ -476,21 +476,21 @@ func TestReportingAnnotationPredicate_Update(t *testing.T) { }{ { name: "enabled, phase changed", - annotations: map[string]string{reporting.AnnotationGitHubReporting: "enabled"}, + annotations: map[string]string{reporting.AnnotationCommentReporting: "enabled"}, oldPhase: kelos.TaskPhasePending, newPhase: kelos.TaskPhaseRunning, want: true, }, { name: "enabled, phase unchanged", - annotations: map[string]string{reporting.AnnotationGitHubReporting: "enabled"}, + annotations: map[string]string{reporting.AnnotationCommentReporting: "enabled"}, oldPhase: kelos.TaskPhaseRunning, newPhase: kelos.TaskPhaseRunning, want: false, }, { name: "checks only, phase changed", - annotations: map[string]string{reporting.AnnotationGitHubChecks: "enabled"}, + annotations: map[string]string{reporting.AnnotationCheckReporting: "enabled"}, oldPhase: kelos.TaskPhasePending, newPhase: kelos.TaskPhaseRunning, want: true, diff --git a/internal/reporting/gitlab.go b/internal/reporting/gitlab.go index df33900e9..accc2f60d 100644 --- a/internal/reporting/gitlab.go +++ b/internal/reporting/gitlab.go @@ -16,17 +16,22 @@ import ( const SourceKindMergeRequest = "merge-request" // GitLabReporter posts and updates notes on GitLab issues and merge requests. -// TokenFunc, when set, is called on every API request; otherwise Token is used. type GitLabReporter struct { // BaseURL is the GitLab instance URL (e.g. "https://gitlab.example.com"). BaseURL string // Project is the full project path (e.g. "group/subgroup/project"). - Project string - Token string + Project string + // TokenFunc is called on every API request so a refreshed token is + // picked up without rebuilding the reporter. TokenFunc func() string Client *http.Client } +// StaticToken adapts a fixed token to the TokenFunc contract. +func StaticToken(token string) func() string { + return func() string { return token } +} + type gitlabNote struct { ID int64 `json:"id"` Body string `json:"body"` @@ -101,7 +106,7 @@ func (r *GitLabReporter) resolveToken() string { if r.TokenFunc != nil { return r.TokenFunc() } - return r.Token + return "" } // do sends a JSON request and decodes the response into out when the status diff --git a/internal/reporting/gitlab_test.go b/internal/reporting/gitlab_test.go index 83bda3c38..ec11f28f0 100644 --- a/internal/reporting/gitlab_test.go +++ b/internal/reporting/gitlab_test.go @@ -47,7 +47,7 @@ func TestGitLabReporterCreateComment(t *testing.T) { server, calls := newGitLabReporterServer(t, nil) defer server.Close() - r := &GitLabReporter{BaseURL: server.URL, Project: "group/sub/repo", Token: "glpat"} + r := &GitLabReporter{BaseURL: server.URL, Project: "group/sub/repo", TokenFunc: StaticToken("glpat")} id, err := r.CreateComment(context.Background(), CommentTarget{Kind: "issue", Number: 42}, "hello") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -68,7 +68,7 @@ func TestGitLabReporterMergeRequestEndpoint(t *testing.T) { server, calls := newGitLabReporterServer(t, nil) defer server.Close() - r := &GitLabReporter{BaseURL: server.URL, Project: "group/repo", Token: "glpat"} + r := &GitLabReporter{BaseURL: server.URL, Project: "group/repo", TokenFunc: StaticToken("glpat")} if err := r.UpdateComment(context.Background(), CommentTarget{Kind: SourceKindMergeRequest, Number: 7}, 555, "updated"); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -91,7 +91,7 @@ func TestGitLabReporterFindCommentByMarker(t *testing.T) { }) defer server.Close() - r := &GitLabReporter{BaseURL: server.URL, Project: "group/repo", Token: "glpat"} + r := &GitLabReporter{BaseURL: server.URL, Project: "group/repo", TokenFunc: StaticToken("glpat")} id, err := r.FindCommentByMarker(context.Background(), CommentTarget{Kind: "issue", Number: 9}, marker) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -125,13 +125,13 @@ func TestTaskReporterUsesGitLabReporter(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("mr-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "7", - AnnotationSourceKind: SourceKindMergeRequest, + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "7", + AnnotationSourceKind: SourceKindMergeRequest, }) tr := &TaskReporter{ Client: fake.NewClientBuilder().WithScheme(newTestScheme()).WithObjects(task).Build(), - Reporter: &GitLabReporter{BaseURL: server.URL, Project: "group/repo", Token: "glpat"}, + Reporter: &GitLabReporter{BaseURL: server.URL, Project: "group/repo", TokenFunc: StaticToken("glpat")}, } if err := tr.ReportTaskStatus(context.Background(), task); err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/reporting/watcher.go b/internal/reporting/watcher.go index 6abfcc054..c6962e022 100644 --- a/internal/reporting/watcher.go +++ b/internal/reporting/watcher.go @@ -17,13 +17,13 @@ import ( ) const ( - // AnnotationGitHubReporting indicates that GitHub comment reporting is - // enabled for this Task. - AnnotationGitHubReporting = "kelos.dev/github-reporting" + // AnnotationCommentReporting indicates that status-comment reporting is + // enabled for this Task, whichever tracker the comment is posted to. + AnnotationCommentReporting = "kelos.dev/comment-reporting" - // AnnotationGitHubCommentMode records whether the reporter creates a + // AnnotationCommentMode records whether the reporter creates a // comment per Task or reuses a sticky comment across Tasks. - AnnotationGitHubCommentMode = "kelos.dev/github-comment-mode" + AnnotationCommentMode = "kelos.dev/comment-mode" // AnnotationSourceKind records whether the source item is an issue or pull-request. AnnotationSourceKind = "kelos.dev/source-kind" @@ -61,33 +61,32 @@ const ( // server). Source-specific webhook handlers leave it unset. AnnotationWebhookGateway = "kelos.dev/webhook-gateway" - // AnnotationGitHubCommentID stores the GitHub comment ID for the status - // comment created by the reporter so subsequent updates edit the same - // comment. - AnnotationGitHubCommentID = "kelos.dev/github-comment-id" + // AnnotationCommentID stores the tracker's ID of the status comment + // created by the reporter so subsequent updates edit the same comment. + AnnotationCommentID = "kelos.dev/comment-id" - // AnnotationGitHubReportPhase records the last Task phase that was - // reported to GitHub, preventing duplicate API calls on re-list. - AnnotationGitHubReportPhase = "kelos.dev/github-report-phase" + // AnnotationCommentReportPhase records the last Task phase that was + // reported as a comment, preventing duplicate API calls on re-list. + AnnotationCommentReportPhase = "kelos.dev/comment-report-phase" - // AnnotationGitHubChecks indicates that GitHub Check Run reporting is - // enabled for this Task. - AnnotationGitHubChecks = "kelos.dev/github-checks" + // AnnotationCheckReporting indicates that commit-check reporting (GitHub + // Check Runs) is enabled for this Task. + AnnotationCheckReporting = "kelos.dev/check-reporting" - // AnnotationGitHubCheckRunID stores the GitHub Check Run ID so - // subsequent updates target the same check run. - AnnotationGitHubCheckRunID = "kelos.dev/github-check-run-id" + // AnnotationCheckRunID stores the tracker's ID of the check so + // subsequent updates target the same one. + AnnotationCheckRunID = "kelos.dev/check-run-id" - // AnnotationGitHubCheckReportPhase records the last Task phase that was - // reported via the Checks API. - AnnotationGitHubCheckReportPhase = "kelos.dev/github-check-report-phase" + // AnnotationCheckReportPhase records the last Task phase that was + // reported as a commit check. + AnnotationCheckReportPhase = "kelos.dev/check-report-phase" // AnnotationSourceSHA records the head commit SHA for pull request sources. AnnotationSourceSHA = "kelos.dev/source-sha" - // AnnotationGitHubCheckName stores the Check Run name configured on the + // AnnotationCheckName stores the check name configured on the // TaskSpawner so the reporter can use it without access to the spec. - AnnotationGitHubCheckName = "kelos.dev/github-check-name" + AnnotationCheckName = "kelos.dev/check-name" // AnnotationSlackReporting indicates that Slack reporting is enabled // for this Task. @@ -114,6 +113,31 @@ const ( LabelSlackReporting = "kelos.dev/slack-reporting" ) +// legacyAnnotationKeys maps the comment and check annotation keys to the +// "github-" keys they carried when GitHub was the only tracker. Tasks stamped +// with the old keys keep reporting to the same comment or check across an +// upgrade; writes always use the current keys. +var legacyAnnotationKeys = map[string]string{ + AnnotationCommentReporting: "kelos.dev/github-reporting", + AnnotationCommentMode: "kelos.dev/github-comment-mode", + AnnotationCommentID: "kelos.dev/github-comment-id", + AnnotationCommentReportPhase: "kelos.dev/github-report-phase", + AnnotationCheckReporting: "kelos.dev/github-checks", + AnnotationCheckRunID: "kelos.dev/github-check-run-id", + AnnotationCheckReportPhase: "kelos.dev/github-check-report-phase", + AnnotationCheckName: "kelos.dev/github-check-name", +} + +// ReadAnnotation returns the value under key, falling back to the key's +// legacy "github-" form. Use it for every read of a comment or check +// annotation; plain map access misses Tasks created before the rename. +func ReadAnnotation(annotations map[string]string, key string) string { + if v, ok := annotations[key]; ok { + return v + } + return annotations[legacyAnnotationKeys[key]] +} + // CommentTarget identifies the issue, pull request, or merge request a // status comment belongs to. Kind carries the AnnotationSourceKind value; // GitLab needs it to pick the notes endpoint, GitHub ignores it. @@ -138,7 +162,7 @@ type TaskReporter struct { Client client.Client Reporter CommentReporter ChecksReporter *ChecksReporter - // Cache backstops AnnotationGitHubCommentID and AnnotationGitHubReportPhase + // Cache backstops AnnotationCommentID and AnnotationCommentReportPhase // when the persisted Update has not yet propagated to the controller-runtime // cache the caller reads from. Optional; when nil, the reporter relies on // annotations alone (which is sufficient for poll-driven callers). @@ -213,8 +237,8 @@ func (tr *TaskReporter) ReportTaskStatus(ctx context.Context, task *kelos.Task) return nil } - commentEnabled := annotations[AnnotationGitHubReporting] == "enabled" - checksEnabled := annotations[AnnotationGitHubChecks] == "enabled" + commentEnabled := ReadAnnotation(annotations, AnnotationCommentReporting) == "enabled" + checksEnabled := ReadAnnotation(annotations, AnnotationCheckReporting) == "enabled" if !commentEnabled && !checksEnabled { return nil @@ -278,11 +302,11 @@ func (tr *TaskReporter) reportViaComment(ctx context.Context, task *kelos.Task) lastReportedPhase = cached.phase commentID = cached.commentID } else { - lastReportedPhase = annotations[AnnotationGitHubReportPhase] - if idStr, ok := annotations[AnnotationGitHubCommentID]; ok { + lastReportedPhase = ReadAnnotation(annotations, AnnotationCommentReportPhase) + if idStr := ReadAnnotation(annotations, AnnotationCommentID); idStr != "" { parsed, err := strconv.ParseInt(idStr, 10, 64) if err != nil { - return fmt.Errorf("parsing %s annotation %q: %w", AnnotationGitHubCommentID, idStr, err) + return fmt.Errorf("parsing %s annotation %q: %w", AnnotationCommentID, idStr, err) } commentID = parsed } @@ -296,8 +320,8 @@ func (tr *TaskReporter) reportViaComment(ctx context.Context, task *kelos.Task) // Cache says we already reported. If the annotation also matches, // nothing to do; otherwise it lags (e.g., previous persist failed) // and we re-attempt persistence so the comment side stays untouched. - if annotations[AnnotationGitHubReportPhase] == desiredPhase && - annotations[AnnotationGitHubCommentID] == strconv.FormatInt(commentID, 10) { + if ReadAnnotation(annotations, AnnotationCommentReportPhase) == desiredPhase && + ReadAnnotation(annotations, AnnotationCommentID) == strconv.FormatInt(commentID, 10) { return nil } return tr.persistReportingState(ctx, task, commentID, desiredPhase) @@ -313,7 +337,7 @@ func (tr *TaskReporter) reportViaComment(ctx context.Context, task *kelos.Task) body = FormatFailedComment(task.Name) } - if annotations[AnnotationGitHubCommentMode] == string(kelos.CommentModeSticky) { + if ReadAnnotation(annotations, AnnotationCommentMode) == string(kelos.CommentModeSticky) { marker, err := stickyCommentMarker(task) if err != nil { return err @@ -368,7 +392,7 @@ func (tr *TaskReporter) reportViaCheckRun(ctx context.Context, task *kelos.Task) return nil } - checkName := annotations[AnnotationGitHubCheckName] + checkName := ReadAnnotation(annotations, AnnotationCheckName) if checkName == "" { spawnerName := task.Labels["kelos.dev/taskspawner"] if spawnerName == "" { @@ -420,12 +444,12 @@ func (tr *TaskReporter) reportViaCheckRun(ctx context.Context, task *kelos.Task) lastCheckPhase = cached.checkPhase checkRunID = cached.checkRunID } else { - lastCheckPhase = annotations[AnnotationGitHubCheckReportPhase] - if idStr, ok := annotations[AnnotationGitHubCheckRunID]; ok { + lastCheckPhase = ReadAnnotation(annotations, AnnotationCheckReportPhase) + if idStr := ReadAnnotation(annotations, AnnotationCheckRunID); idStr != "" { var err error checkRunID, err = strconv.ParseInt(idStr, 10, 64) if err != nil { - return fmt.Errorf("parsing %s annotation %q: %w", AnnotationGitHubCheckRunID, idStr, err) + return fmt.Errorf("parsing %s annotation %q: %w", AnnotationCheckRunID, idStr, err) } } } @@ -434,8 +458,8 @@ func (tr *TaskReporter) reportViaCheckRun(ctx context.Context, task *kelos.Task) if !hasCached || cached.checkRunID == 0 { return nil } - if annotations[AnnotationGitHubCheckReportPhase] == desiredPhase && - annotations[AnnotationGitHubCheckRunID] == strconv.FormatInt(checkRunID, 10) { + if ReadAnnotation(annotations, AnnotationCheckReportPhase) == desiredPhase && + ReadAnnotation(annotations, AnnotationCheckRunID) == strconv.FormatInt(checkRunID, 10) { return nil } return tr.persistCheckRunState(ctx, task, checkRunID, desiredPhase) @@ -465,15 +489,15 @@ func (tr *TaskReporter) reportViaCheckRun(ctx context.Context, task *kelos.Task) func (tr *TaskReporter) persistReportingState(ctx context.Context, task *kelos.Task, commentID int64, desiredPhase string) error { return tr.persistAnnotations(ctx, task, map[string]string{ - AnnotationGitHubCommentID: strconv.FormatInt(commentID, 10), - AnnotationGitHubReportPhase: desiredPhase, + AnnotationCommentID: strconv.FormatInt(commentID, 10), + AnnotationCommentReportPhase: desiredPhase, }) } func (tr *TaskReporter) persistCheckRunState(ctx context.Context, task *kelos.Task, checkRunID int64, desiredPhase string) error { return tr.persistAnnotations(ctx, task, map[string]string{ - AnnotationGitHubCheckRunID: strconv.FormatInt(checkRunID, 10), - AnnotationGitHubCheckReportPhase: desiredPhase, + AnnotationCheckRunID: strconv.FormatInt(checkRunID, 10), + AnnotationCheckReportPhase: desiredPhase, }) } diff --git a/internal/reporting/watcher_test.go b/internal/reporting/watcher_test.go index 22ed68f6a..0390fd439 100644 --- a/internal/reporting/watcher_test.go +++ b/internal/reporting/watcher_test.go @@ -171,9 +171,9 @@ func TestReportTaskStatus_CreatesCommentOnPending(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) cl := fake.NewClientBuilder(). @@ -209,10 +209,10 @@ func TestReportTaskStatus_CreatesCommentOnPending(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubReportPhase] != "accepted" { - t.Errorf("Expected report phase 'accepted', got %q", updated.Annotations[AnnotationGitHubReportPhase]) + if updated.Annotations[AnnotationCommentReportPhase] != "accepted" { + t.Errorf("Expected report phase 'accepted', got %q", updated.Annotations[AnnotationCommentReportPhase]) } - if updated.Annotations[AnnotationGitHubCommentID] == "" { + if updated.Annotations[AnnotationCommentID] == "" { t.Error("Expected comment ID to be set") } } @@ -223,10 +223,10 @@ func TestReportTaskStatus_StickyCommentReusedAcrossTasks(t *testing.T) { annotations := func() map[string]string { return map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationCommentMode: string(kelos.CommentModeSticky), + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", } } first := newTaskWithAnnotations("first-task", "default", kelos.TaskPhasePending, annotations()) @@ -273,10 +273,10 @@ func TestReportTaskStatus_StickyCommentsScopedByTaskSpawner(t *testing.T) { annotations := func() map[string]string { return map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationCommentMode: string(kelos.CommentModeSticky), + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", } } first := newTaskWithAnnotations("first-task", "default", kelos.TaskPhasePending, annotations()) @@ -321,11 +321,11 @@ func TestReportTaskStatus_UpdatesCommentOnSucceeded(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseSucceeded, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", - AnnotationGitHubCommentID: "5555", - AnnotationGitHubReportPhase: "accepted", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", + AnnotationCommentID: "5555", + AnnotationCommentReportPhase: "accepted", }) cl := fake.NewClientBuilder(). @@ -360,8 +360,8 @@ func TestReportTaskStatus_UpdatesCommentOnSucceeded(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubReportPhase] != "succeeded" { - t.Errorf("Expected report phase 'succeeded', got %q", updated.Annotations[AnnotationGitHubReportPhase]) + if updated.Annotations[AnnotationCommentReportPhase] != "succeeded" { + t.Errorf("Expected report phase 'succeeded', got %q", updated.Annotations[AnnotationCommentReportPhase]) } } @@ -370,11 +370,11 @@ func TestReportTaskStatus_UpdatesCommentOnFailed(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseFailed, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", - AnnotationGitHubCommentID: "5555", - AnnotationGitHubReportPhase: "accepted", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", + AnnotationCommentID: "5555", + AnnotationCommentReportPhase: "accepted", }) cl := fake.NewClientBuilder(). @@ -406,8 +406,8 @@ func TestReportTaskStatus_UpdatesCommentOnFailed(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubReportPhase] != "failed" { - t.Errorf("Expected report phase 'failed', got %q", updated.Annotations[AnnotationGitHubReportPhase]) + if updated.Annotations[AnnotationCommentReportPhase] != "failed" { + t.Errorf("Expected report phase 'failed', got %q", updated.Annotations[AnnotationCommentReportPhase]) } } @@ -416,11 +416,11 @@ func TestReportTaskStatus_SkipsDuplicateReport(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", - AnnotationGitHubCommentID: "5555", - AnnotationGitHubReportPhase: "accepted", // Already reported + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", + AnnotationCommentID: "5555", + AnnotationCommentReportPhase: "accepted", // Already reported }) cl := fake.NewClientBuilder(). @@ -457,7 +457,7 @@ func TestReportTaskStatus_SkipsWithoutReportingAnnotation(t *testing.T) { task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ AnnotationSourceNumber: "42", AnnotationSourceKind: "issue", - // No AnnotationGitHubReporting + // No AnnotationCommentReporting }) cl := fake.NewClientBuilder(). @@ -491,9 +491,9 @@ func TestReportTaskStatus_SkipsEmptyPhase(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", "", map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) cl := fake.NewClientBuilder(). @@ -527,9 +527,9 @@ func TestReportTaskStatus_RunningMapsToAccepted(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseRunning, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) cl := fake.NewClientBuilder(). @@ -561,8 +561,8 @@ func TestReportTaskStatus_RunningMapsToAccepted(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubReportPhase] != "accepted" { - t.Errorf("Expected report phase 'accepted' for Running task, got %q", updated.Annotations[AnnotationGitHubReportPhase]) + if updated.Annotations[AnnotationCommentReportPhase] != "accepted" { + t.Errorf("Expected report phase 'accepted' for Running task, got %q", updated.Annotations[AnnotationCommentReportPhase]) } } @@ -572,9 +572,9 @@ func TestReportTaskStatus_CreatesNewCommentWhenNoCommentID(t *testing.T) { // Task with succeeded phase but no comment ID (e.g. short-lived task) task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseSucceeded, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) cl := fake.NewClientBuilder(). @@ -610,9 +610,9 @@ func TestReportTaskStatus_CreatesNewCommentWhenNoCommentID(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - commentID, err := strconv.ParseInt(updated.Annotations[AnnotationGitHubCommentID], 10, 64) + commentID, err := strconv.ParseInt(updated.Annotations[AnnotationCommentID], 10, 64) if err != nil || commentID == 0 { - t.Errorf("Expected valid comment ID, got %q", updated.Annotations[AnnotationGitHubCommentID]) + t.Errorf("Expected valid comment ID, got %q", updated.Annotations[AnnotationCommentID]) } } @@ -621,9 +621,9 @@ func TestReportTaskStatus_RetriesAnnotationPersistenceOnConflict(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) baseClient := fake.NewClientBuilder(). @@ -663,20 +663,20 @@ func TestReportTaskStatus_RetriesAnnotationPersistenceOnConflict(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubReportPhase] != "accepted" { - t.Errorf("Expected report phase 'accepted', got %q", updated.Annotations[AnnotationGitHubReportPhase]) + if updated.Annotations[AnnotationCommentReportPhase] != "accepted" { + t.Errorf("Expected report phase 'accepted', got %q", updated.Annotations[AnnotationCommentReportPhase]) } - if updated.Annotations[AnnotationGitHubCommentID] == "" { + if updated.Annotations[AnnotationCommentID] == "" { t.Error("Expected comment ID to be set") } } func TestReportTaskStatus_CorruptedCommentIDReturnsError(t *testing.T) { task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", - AnnotationGitHubCommentID: "not-a-number", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", + AnnotationCommentID: "not-a-number", }) cl := fake.NewClientBuilder(). @@ -698,9 +698,9 @@ func TestReportTaskStatus_CachePopulatedAfterCreate(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) task.UID = types.UID("uid-create") @@ -739,9 +739,9 @@ func TestReportTaskStatus_CacheFallbackUpdatesExistingComment(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseSucceeded, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) task.UID = types.UID("uid-fallback") @@ -771,19 +771,69 @@ func TestReportTaskStatus_CacheFallbackUpdatesExistingComment(t *testing.T) { } } +// TestReportTaskStatus_LegacyAnnotationKeysUpdateExistingComment covers a +// Task stamped before the annotation keys lost their "github-" prefix: the +// reporter must find the existing comment through the legacy keys and update +// it instead of posting a duplicate. +func TestReportTaskStatus_LegacyAnnotationKeysUpdateExistingComment(t *testing.T) { + server, records := newTestServer(t) + defer server.Close() + + task := newTaskWithAnnotations("legacy-task", "default", kelos.TaskPhaseSucceeded, map[string]string{ + "kelos.dev/github-reporting": "enabled", + "kelos.dev/github-comment-id": "4242", + "kelos.dev/github-report-phase": "accepted", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", + }) + cl := fake.NewClientBuilder().WithScheme(newTestScheme()).WithObjects(task).Build() + tr := &TaskReporter{ + Client: cl, + Reporter: &GitHubReporter{Owner: "owner", Repo: "repo", Token: "token", BaseURL: server.URL}, + } + + if err := tr.ReportTaskStatus(context.Background(), task); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(*records) != 1 || (*records)[0].method != "update" || (*records)[0].id != 4242 { + t.Fatalf("expected one update of comment 4242, got %+v", *records) + } + + updated := &kelos.Task{} + if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), updated); err != nil { + t.Fatal(err) + } + if updated.Annotations[AnnotationCommentID] != "4242" || updated.Annotations[AnnotationCommentReportPhase] != "succeeded" { + t.Errorf("expected state persisted under the current keys, got %v", updated.Annotations) + } +} + +func TestReadAnnotationPrefersCurrentKey(t *testing.T) { + both := map[string]string{AnnotationCommentID: "1", "kelos.dev/github-comment-id": "2"} + if got := ReadAnnotation(both, AnnotationCommentID); got != "1" { + t.Errorf("ReadAnnotation() = %q, want current key to win", got) + } + if got := ReadAnnotation(map[string]string{"kelos.dev/github-check-name": "ci"}, AnnotationCheckName); got != "ci" { + t.Errorf("ReadAnnotation() = %q, want legacy fallback", got) + } + if got := ReadAnnotation(nil, AnnotationSourceKind); got != "" { + t.Errorf("ReadAnnotation() on nil = %q, want empty", got) + } +} + // TestReportTaskStatus_CacheShortCircuitsDuplicateReport simulates two // reconciles firing for the same phase before the annotation Update has // propagated to the cached read. The first call posts the comment; the second // must not post a duplicate even though the Task object it sees still has no -// AnnotationGitHubReportPhase. +// AnnotationCommentReportPhase. func TestReportTaskStatus_CacheShortCircuitsDuplicateReport(t *testing.T) { server, records := newTestServer(t) defer server.Close() annotations := map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", } first := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, annotations) @@ -807,9 +857,9 @@ func TestReportTaskStatus_CacheShortCircuitsDuplicateReport(t *testing.T) { // Simulate a stale cached read: a second copy of the Task that has not yet // observed the annotation Update from the first reconcile. stale := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) stale.UID = types.UID("uid-shortcircuit") @@ -834,11 +884,11 @@ func TestReportTaskStatus_SkipsRepeatedNoOpPersist(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", - AnnotationGitHubCommentID: "9999", - AnnotationGitHubReportPhase: "accepted", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", + AnnotationCommentID: "9999", + AnnotationCommentReportPhase: "accepted", }) task.UID = types.UID("uid-noop") @@ -872,9 +922,9 @@ func TestReportTaskStatus_NilCache(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "issue", + AnnotationCommentReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "issue", }) tr := &TaskReporter{ @@ -956,9 +1006,9 @@ func TestReportTaskStatus_CreatesCheckRunOnPending(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", }) task.Labels = map[string]string{"kelos.dev/taskspawner": "my-spawner"} @@ -1004,10 +1054,10 @@ func TestReportTaskStatus_CreatesCheckRunOnPending(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubCheckReportPhase] != "in_progress" { - t.Errorf("Expected check report phase 'in_progress', got %q", updated.Annotations[AnnotationGitHubCheckReportPhase]) + if updated.Annotations[AnnotationCheckReportPhase] != "in_progress" { + t.Errorf("Expected check report phase 'in_progress', got %q", updated.Annotations[AnnotationCheckReportPhase]) } - if updated.Annotations[AnnotationGitHubCheckRunID] == "" { + if updated.Annotations[AnnotationCheckRunID] == "" { t.Error("Expected check run ID to be set") } } @@ -1017,11 +1067,11 @@ func TestReportTaskStatus_UpdatesCheckRunOnSucceeded(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseSucceeded, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", - AnnotationGitHubCheckRunID: "5001", - AnnotationGitHubCheckReportPhase: "in_progress", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", + AnnotationCheckRunID: "5001", + AnnotationCheckReportPhase: "in_progress", }) task.Labels = map[string]string{"kelos.dev/taskspawner": "my-spawner"} @@ -1064,8 +1114,8 @@ func TestReportTaskStatus_UpdatesCheckRunOnSucceeded(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubCheckReportPhase] != "succeeded" { - t.Errorf("Expected check report phase 'succeeded', got %q", updated.Annotations[AnnotationGitHubCheckReportPhase]) + if updated.Annotations[AnnotationCheckReportPhase] != "succeeded" { + t.Errorf("Expected check report phase 'succeeded', got %q", updated.Annotations[AnnotationCheckReportPhase]) } } @@ -1074,11 +1124,11 @@ func TestReportTaskStatus_UpdatesCheckRunOnFailed(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseFailed, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", - AnnotationGitHubCheckRunID: "5001", - AnnotationGitHubCheckReportPhase: "in_progress", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", + AnnotationCheckRunID: "5001", + AnnotationCheckReportPhase: "in_progress", }) task.Labels = map[string]string{"kelos.dev/taskspawner": "my-spawner"} @@ -1115,8 +1165,8 @@ func TestReportTaskStatus_UpdatesCheckRunOnFailed(t *testing.T) { if err := cl.Get(context.Background(), client.ObjectKeyFromObject(task), &updated); err != nil { t.Fatalf("Getting updated task: %v", err) } - if updated.Annotations[AnnotationGitHubCheckReportPhase] != "failed" { - t.Errorf("Expected check report phase 'failed', got %q", updated.Annotations[AnnotationGitHubCheckReportPhase]) + if updated.Annotations[AnnotationCheckReportPhase] != "failed" { + t.Errorf("Expected check report phase 'failed', got %q", updated.Annotations[AnnotationCheckReportPhase]) } } @@ -1125,10 +1175,10 @@ func TestReportTaskStatus_SkipsDuplicateCheckReport(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckRunID: "5001", - AnnotationGitHubCheckReportPhase: "in_progress", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckRunID: "5001", + AnnotationCheckReportPhase: "in_progress", }) cl := fake.NewClientBuilder(). @@ -1163,7 +1213,7 @@ func TestReportTaskStatus_ChecksSkipsWithoutSHA(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubChecks: "enabled", + AnnotationCheckReporting: "enabled", // No AnnotationSourceSHA }) @@ -1202,12 +1252,12 @@ func TestReportTaskStatus_BothCommentAndChecks(t *testing.T) { defer checksServer.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubReporting: "enabled", - AnnotationGitHubChecks: "enabled", - AnnotationSourceNumber: "42", - AnnotationSourceKind: "pull-request", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", + AnnotationCommentReporting: "enabled", + AnnotationCheckReporting: "enabled", + AnnotationSourceNumber: "42", + AnnotationSourceKind: "pull-request", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", }) task.Labels = map[string]string{"kelos.dev/taskspawner": "my-spawner"} @@ -1249,9 +1299,9 @@ func TestReportTaskStatus_ChecksFallbackName(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - // No AnnotationGitHubCheckName — should fall back to label + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + // No AnnotationCheckName — should fall back to label }) task.Labels = map[string]string{"kelos.dev/taskspawner": "fallback-spawner"} @@ -1283,9 +1333,9 @@ func TestReportTaskStatus_CheckRunCachePopulatedAfterCreate(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", }) task.UID = types.UID("uid-check-create") @@ -1322,9 +1372,9 @@ func TestReportTaskStatus_CheckRunCacheFallbackUpdatesExisting(t *testing.T) { defer server.Close() task := newTaskWithAnnotations("test-task", "default", kelos.TaskPhaseSucceeded, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", }) task.UID = types.UID("uid-check-fallback") @@ -1359,9 +1409,9 @@ func TestReportTaskStatus_CheckRunCacheShortCircuitsDuplicate(t *testing.T) { defer server.Close() annotations := map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", } first := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, annotations) @@ -1384,9 +1434,9 @@ func TestReportTaskStatus_CheckRunCacheShortCircuitsDuplicate(t *testing.T) { // Simulate a stale cached read: a second copy of the Task that has not yet // observed the annotation Update from the first reconcile. stale := newTaskWithAnnotations("test-task", "default", kelos.TaskPhasePending, map[string]string{ - AnnotationGitHubChecks: "enabled", - AnnotationSourceSHA: "abc123def", - AnnotationGitHubCheckName: "Kelos: my-spawner", + AnnotationCheckReporting: "enabled", + AnnotationSourceSHA: "abc123def", + AnnotationCheckName: "Kelos: my-spawner", }) stale.UID = types.UID("uid-check-shortcircuit") diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go index d28cf00cc..f0f53ce0d 100644 --- a/internal/webhook/handler_test.go +++ b/internal/webhook/handler_test.go @@ -695,11 +695,11 @@ func TestServeHTTP_StampsStickyCommentReportingAnnotations(t *testing.T) { } task := taskList.Items[0] - if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) + if task.Annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationCommentReporting]) } - if task.Annotations[reporting.AnnotationGitHubCommentMode] != string(kelos.CommentModeSticky) { - t.Errorf("Expected Sticky comment mode, got %q", task.Annotations[reporting.AnnotationGitHubCommentMode]) + if task.Annotations[reporting.AnnotationCommentMode] != string(kelos.CommentModeSticky) { + t.Errorf("Expected Sticky comment mode, got %q", task.Annotations[reporting.AnnotationCommentMode]) } if task.Annotations[reporting.AnnotationSourceKind] != "issue" { t.Errorf("Expected source-kind 'issue', got %q", task.Annotations[reporting.AnnotationSourceKind]) @@ -767,7 +767,7 @@ func TestServeHTTP_NoReportingAnnotationsWhenDisabled(t *testing.T) { } task := taskList.Items[0] - if _, ok := task.Annotations[reporting.AnnotationGitHubReporting]; ok { + if _, ok := task.Annotations[reporting.AnnotationCommentReporting]; ok { t.Error("Expected no github-reporting annotation when reporting is not enabled") } } @@ -839,8 +839,8 @@ func TestServeHTTP_ReportingAnnotationsPullRequest(t *testing.T) { } task := taskList.Items[0] - if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) + if task.Annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationCommentReporting]) } if task.Annotations[reporting.AnnotationSourceKind] != "pull-request" { t.Errorf("Expected source-kind 'pull-request', got %q", task.Annotations[reporting.AnnotationSourceKind]) @@ -1228,8 +1228,8 @@ func TestServeHTTP_IssueCommentOnPR_EnrichesBranch(t *testing.T) { if task.Spec.Prompt != "Review PR on branch feature-branch" { t.Errorf("Expected prompt with enriched branch, got %q", task.Spec.Prompt) } - if task.Annotations[reporting.AnnotationGitHubChecks] != "enabled" { - t.Errorf("Expected github-checks 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubChecks]) + if task.Annotations[reporting.AnnotationCheckReporting] != "enabled" { + t.Errorf("Expected github-checks 'enabled', got %q", task.Annotations[reporting.AnnotationCheckReporting]) } if task.Annotations[reporting.AnnotationSourceSHA] != "enriched-sha-456" { t.Errorf("Expected source-sha 'enriched-sha-456', got %q", task.Annotations[reporting.AnnotationSourceSHA]) @@ -1520,14 +1520,14 @@ func TestGitLabServeHTTP_StampsReportingAnnotations(t *testing.T) { } got := taskList.Items[0].Annotations want := map[string]string{ - reporting.AnnotationSourceProvider: reporting.SourceProviderGitLab, - reporting.AnnotationSourceKind: reporting.SourceKindMergeRequest, - reporting.AnnotationSourceNumber: "7", - reporting.AnnotationSourceRepo: "group/sub/repo", - reporting.AnnotationSourceBaseURL: "https://gitlab.example.com", - reporting.AnnotationWebhookGateway: "gl-gateway", - reporting.AnnotationGitHubReporting: "enabled", - reporting.AnnotationGitHubCommentMode: string(kelos.CommentModeSticky), + reporting.AnnotationSourceProvider: reporting.SourceProviderGitLab, + reporting.AnnotationSourceKind: reporting.SourceKindMergeRequest, + reporting.AnnotationSourceNumber: "7", + reporting.AnnotationSourceRepo: "group/sub/repo", + reporting.AnnotationSourceBaseURL: "https://gitlab.example.com", + reporting.AnnotationWebhookGateway: "gl-gateway", + reporting.AnnotationCommentReporting: "enabled", + reporting.AnnotationCommentMode: string(kelos.CommentModeSticky), } for k, v := range want { if got[k] != v { @@ -1566,7 +1566,7 @@ func TestGitLabServeHTTP_NoReportingAnnotationsWithoutReporting(t *testing.T) { if len(taskList.Items) != 1 { t.Fatalf("Expected 1 task, got %d", len(taskList.Items)) } - if _, ok := taskList.Items[0].Annotations[reporting.AnnotationGitHubReporting]; ok { + if _, ok := taskList.Items[0].Annotations[reporting.AnnotationCommentReporting]; ok { t.Error("Expected no reporting annotation when gitlabWebhook.reporting is unset") } } @@ -2404,17 +2404,17 @@ func TestServeHTTP_ChecksAnnotationsForPRWebhook(t *testing.T) { } task := taskList.Items[0] - if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) + if task.Annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationCommentReporting]) } - if task.Annotations[reporting.AnnotationGitHubChecks] != "enabled" { - t.Errorf("Expected github-checks 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubChecks]) + if task.Annotations[reporting.AnnotationCheckReporting] != "enabled" { + t.Errorf("Expected github-checks 'enabled', got %q", task.Annotations[reporting.AnnotationCheckReporting]) } if task.Annotations[reporting.AnnotationSourceSHA] != "deadbeef123" { t.Errorf("Expected source-sha 'deadbeef123', got %q", task.Annotations[reporting.AnnotationSourceSHA]) } - if task.Annotations[reporting.AnnotationGitHubCheckName] != "My Check" { - t.Errorf("Expected check name 'My Check', got %q", task.Annotations[reporting.AnnotationGitHubCheckName]) + if task.Annotations[reporting.AnnotationCheckName] != "My Check" { + t.Errorf("Expected check name 'My Check', got %q", task.Annotations[reporting.AnnotationCheckName]) } if task.Annotations[reporting.AnnotationSourceKind] != "pull-request" { t.Errorf("Expected source-kind 'pull-request', got %q", task.Annotations[reporting.AnnotationSourceKind]) @@ -2480,10 +2480,10 @@ func TestServeHTTP_ChecksAnnotationsSkippedForIssueComment(t *testing.T) { } task := taskList.Items[0] - if task.Annotations[reporting.AnnotationGitHubReporting] != "enabled" { - t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubReporting]) + if task.Annotations[reporting.AnnotationCommentReporting] != "enabled" { + t.Errorf("Expected github-reporting 'enabled', got %q", task.Annotations[reporting.AnnotationCommentReporting]) } - if _, ok := task.Annotations[reporting.AnnotationGitHubChecks]; ok { + if _, ok := task.Annotations[reporting.AnnotationCheckReporting]; ok { t.Error("Expected no github-checks annotation for issue comment") } if _, ok := task.Annotations[reporting.AnnotationSourceSHA]; ok { @@ -2558,12 +2558,12 @@ func TestServeHTTP_ChecksOnlyWithoutCommentReporting(t *testing.T) { task := taskList.Items[0] // Comment reporting should NOT be set - if _, ok := task.Annotations[reporting.AnnotationGitHubReporting]; ok { + if _, ok := task.Annotations[reporting.AnnotationCommentReporting]; ok { t.Error("Expected no github-reporting annotation when Enabled is false") } // Checks should be set - if task.Annotations[reporting.AnnotationGitHubChecks] != "enabled" { - t.Errorf("Expected github-checks 'enabled', got %q", task.Annotations[reporting.AnnotationGitHubChecks]) + if task.Annotations[reporting.AnnotationCheckReporting] != "enabled" { + t.Errorf("Expected github-checks 'enabled', got %q", task.Annotations[reporting.AnnotationCheckReporting]) } if task.Annotations[reporting.AnnotationSourceSHA] != "aaa111bbb222" { t.Errorf("Expected source-sha 'aaa111bbb222', got %q", task.Annotations[reporting.AnnotationSourceSHA]) diff --git a/internal/webhook/provider.go b/internal/webhook/provider.go index f01519add..62a69b80f 100644 --- a/internal/webhook/provider.go +++ b/internal/webhook/provider.go @@ -80,8 +80,8 @@ func reportingAnnotations(tracker kelos.TrackerSource, kind string, number int, annotations[reporting.AnnotationWebhookGateway] = gatewayName } if tracker.Comments != nil { - annotations[reporting.AnnotationGitHubReporting] = "enabled" - annotations[reporting.AnnotationGitHubCommentMode] = string(tracker.CommentMode()) + annotations[reporting.AnnotationCommentReporting] = "enabled" + annotations[reporting.AnnotationCommentMode] = string(tracker.CommentMode()) } return annotations } diff --git a/internal/webhook/provider_github.go b/internal/webhook/provider_github.go index 3cc9e428c..3aae21f22 100644 --- a/internal/webhook/provider_github.go +++ b/internal/webhook/provider_github.go @@ -63,10 +63,10 @@ func (githubProvider) annotate(task *kelos.Task, spawner *kelos.TaskSpawner, eve annotations[reporting.AnnotationSourceOwner] = parsed.GitHub.RepositoryOwner annotations[reporting.AnnotationSourceRepo] = parsed.GitHub.RepositoryName if tracker.Checks != nil && parsed.GitHub.HeadSHA != "" { - annotations[reporting.AnnotationGitHubChecks] = "enabled" + annotations[reporting.AnnotationCheckReporting] = "enabled" annotations[reporting.AnnotationSourceSHA] = parsed.GitHub.HeadSHA if tracker.Checks.Name != "" { - annotations[reporting.AnnotationGitHubCheckName] = tracker.Checks.Name + annotations[reporting.AnnotationCheckName] = tracker.Checks.Name } } addAnnotations(task, annotations) From 2ba1124ffb04705f4311f9ac5cac581fb5278689 Mon Sep 17 00:00:00 2001 From: Jan Soukup Date: Thu, 3 Sep 2026 15:27:16 +0200 Subject: [PATCH 7/7] feat(openrouter): support added for openrouter via opencode --- docs/agent-image-interface.md | 2 +- examples/19-taskspawner-gitlab/README.md | 6 +++-- .../taskspawner-webhook.yaml | 20 ++++++++++++++-- internal/controller/entrypoint_test.go | 24 +++++++++++++++++++ internal/sessionruntime/opencode.go | 2 ++ internal/sessionruntime/opencode_test.go | 1 + opencode/kelos_entrypoint.sh | 1 + 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/agent-image-interface.md b/docs/agent-image-interface.md index b79ffd5f4..c67f7cdf8 100644 --- a/docs/agent-image-interface.md +++ b/docs/agent-image-interface.md @@ -76,7 +76,7 @@ Kelos sets the following reserved environment variables on agent containers: | `CODEX_API_KEY` | API key for OpenAI Codex (`codex` agent, `api-key` credential type) | When credential type is `api-key` and agent type is `codex` | | `CODEX_AUTH_JSON` | Contents of `~/.codex/auth.json` (`codex` agent, `oauth` credential type) | When credential type is `oauth` and agent type is `codex` | | `GEMINI_API_KEY` | API key for Google Gemini (`gemini` agent, api-key or oauth credential type) | When agent type is `gemini` | -| `OPENCODE_API_KEY` | API key for OpenCode (`opencode` agent, api-key or oauth credential type). The OpenCode entrypoint maps this for supported provider prefixes, including `ZHIPU_API_KEY` for `zai/*` models. | When agent type is `opencode` | +| `OPENCODE_API_KEY` | API key for OpenCode (`opencode` agent, api-key or oauth credential type). The OpenCode entrypoint maps this for supported provider prefixes, including `ZHIPU_API_KEY` for `zai/*` models and `OPENROUTER_API_KEY` for `openrouter/*` models. | When agent type is `opencode` | | `CURSOR_API_KEY` | API key for Cursor CLI (`cursor` agent, api-key or oauth credential type) | When agent type is `cursor` | | `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token (`claude-code` agent, oauth credential type) | When credential type is `oauth` and agent type is `claude-code` | | `GITHUB_TOKEN` | GitHub token for workspace access. **Captured at pod start and not refreshed in-process — custom images should read `KELOS_GITHUB_TOKEN_FILE` instead (see [GitHub token freshness](#github-token-freshness)).** | When a `github` workspace has a `secretRef` | diff --git a/examples/19-taskspawner-gitlab/README.md b/examples/19-taskspawner-gitlab/README.md index dc49ac7a2..1637765c5 100644 --- a/examples/19-taskspawner-gitlab/README.md +++ b/examples/19-taskspawner-gitlab/README.md @@ -107,8 +107,10 @@ interval. It needs the GitLab webhook server: set values (see [`examples/helm-values-webhook.yaml`](../helm-values-webhook.yaml)), then add a project webhook in GitLab (Settings → Webhooks) with the URL `https:///webhook/gitlab`, the token from -`gitlab-webhook-secret.yaml`, and the **Comments** and **Pipeline events** -triggers enabled. For an in-cluster GitLab, point the webhook at the +`gitlab-webhook-secret.yaml`, and the **Issues events**, **Comments** and +**Pipeline events** triggers enabled. Filters accept `labels` (all required) +and `excludeLabels` (any rejects) on `issue`, `merge_request` and `note` +events; note filters use the labels of the commented issue or merge request. For an in-cluster GitLab, point the webhook at the `kelos-webhook-gitlab` Service and allow local network requests in the GitLab admin settings (Admin → Settings → Network → Outbound requests). To get status notes from webhook-created Tasks, also set diff --git a/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml b/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml index 383dd235b..3e3821212 100644 --- a/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml +++ b/examples/19-taskspawner-gitlab/taskspawner-webhook.yaml @@ -10,21 +10,37 @@ spec: when: gitlabWebhook: events: + - issue - note - pipeline # TODO: Replace with your project path project: group/repo filters: + # An issue labeled "kelos" is opened or relabeled. `labels` requires + # every listed label, `excludeLabels` rejects any listed label; both + # match case-insensitively. Omit `action` to match every action. + - event: issue + action: open + labels: [kelos] + excludeLabels: [wontfix] + - event: issue + action: update + labels: [kelos] + excludeLabels: [wontfix] # A "/kelos fix" comment on a merge request or issue. Notes on commits # and snippets carry neither a branch nor a number and are excluded so - # the branch template below never renders "kelos-issue-". + # the branch template below never renders "kelos-issue-". Label filters + # on notes apply to the commented merge request or issue. - event: note noteOn: MergeRequest bodyPattern: '^/kelos fix' + excludeLabels: [wontfix] - event: note noteOn: Issue bodyPattern: '^/kelos fix' - # A merge request pipeline failed. + excludeLabels: [wontfix] + # A merge request pipeline failed. Pipeline and push events carry no + # labels, so label filters never match on them. - event: pipeline status: failed # Status notes need webhookServer.sources.gitlab.tokenSecretName. diff --git a/internal/controller/entrypoint_test.go b/internal/controller/entrypoint_test.go index e54bdd7ef..a5b037c6f 100644 --- a/internal/controller/entrypoint_test.go +++ b/internal/controller/entrypoint_test.go @@ -287,6 +287,30 @@ func TestOpenCodeEntrypointMapsZAIProviderKey(t *testing.T) { } } +func TestOpenCodeEntrypointMapsOpenRouterProviderKey(t *testing.T) { + tmp := t.TempDir() + section := extractEntrypointSection(t, "../..//opencode/kelos_entrypoint.sh", "# Map OPENCODE_API_KEY to the correct provider environment variable", "if [ -n \"${KELOS_EFFORT:-}\" ]; then") + script := filepath.Join(tmp, "map-opencode-key.sh") + writeFile(t, script, "#!/usr/bin/env bash\nset -euo pipefail\n"+section+"\nprintf '%s|%s' \"${OPENROUTER_API_KEY:-}\" \"${ANTHROPIC_API_KEY:-}\"\n") + if err := os.Chmod(script, 0o755); err != nil { + t.Fatalf("chmod script: %v", err) + } + + cmd := exec.Command("bash", script) + cmd.Env = append(os.Environ(), + "KELOS_MODEL=openrouter/qwen/qwen3-coder", + "OPENCODE_API_KEY=test-openrouter-key", + "ANTHROPIC_API_KEY=", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("running OpenCode provider key mapping: %v\n%s", err, output) + } + if got := string(output); got != "test-openrouter-key|" { + t.Fatalf("OPENROUTER_API_KEY|ANTHROPIC_API_KEY = %q, want test-openrouter-key|", got) + } +} + func TestAgentEntrypointsPreserveSkillReferenceFiles(t *testing.T) { tests := []struct { name string diff --git a/internal/sessionruntime/opencode.go b/internal/sessionruntime/opencode.go index 238dcb8fc..e14c02182 100644 --- a/internal/sessionruntime/opencode.go +++ b/internal/sessionruntime/opencode.go @@ -1132,6 +1132,8 @@ func openCodeCommandEnvironment(current []string, model string) []string { name = "GROQ_API_KEY" case "xai": name = "XAI_API_KEY" + case "openrouter": + name = "OPENROUTER_API_KEY" case "zai", "zai-coding-plan": name = "ZHIPU_API_KEY" case "opencode", "zen": diff --git a/internal/sessionruntime/opencode_test.go b/internal/sessionruntime/opencode_test.go index afa1955ae..df1c67808 100644 --- a/internal/sessionruntime/opencode_test.go +++ b/internal/sessionruntime/opencode_test.go @@ -325,6 +325,7 @@ func TestOpenCodeCommandEnvironmentMapsProviderKey(t *testing.T) { {model: "openai/gpt-5", name: "OPENAI_API_KEY"}, {model: "google/gemini", name: "GEMINI_API_KEY"}, {model: "zai/glm", name: "ZHIPU_API_KEY"}, + {model: "openrouter/qwen/qwen3-coder", name: "OPENROUTER_API_KEY"}, } for _, test := range tests { t.Run(test.model, func(t *testing.T) { diff --git a/opencode/kelos_entrypoint.sh b/opencode/kelos_entrypoint.sh index 3948d8987..4a33f3be7 100755 --- a/opencode/kelos_entrypoint.sh +++ b/opencode/kelos_entrypoint.sh @@ -27,6 +27,7 @@ if [ -n "${OPENCODE_API_KEY:-}" ] && [ -n "${KELOS_MODEL:-}" ]; then google) export GEMINI_API_KEY="$OPENCODE_API_KEY" ;; groq) export GROQ_API_KEY="$OPENCODE_API_KEY" ;; xai) export XAI_API_KEY="$OPENCODE_API_KEY" ;; + openrouter) export OPENROUTER_API_KEY="$OPENCODE_API_KEY" ;; zai | zai-coding-plan) export ZHIPU_API_KEY="$OPENCODE_API_KEY" ;; opencode | zen) # Zen/OpenCode models: no provider-specific key mapping needed.