-
Notifications
You must be signed in to change notification settings - Fork 94
feat(poll): implement Phase 2 cron poller for GitLab event dispatch #4100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/fullsend-ai/fullsend/internal/poll" | ||
| ) | ||
|
|
||
| func newPollCmd() *cobra.Command { | ||
| var ( | ||
| forgeFlag string | ||
| projectPath string | ||
| gitlabURL string | ||
| outputPath string | ||
| pollModeFlag string | ||
| ) | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "poll", | ||
| Short: "Poll GitLab API for new events and dispatch agent stages", | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| if forgeFlag != "gitlab" { | ||
| return fmt.Errorf("poll command currently supports --forge gitlab only (got %q)", forgeFlag) | ||
| } | ||
|
|
||
| forgeToken := os.Getenv("FULLSEND_FORGE_TOKEN") | ||
| if forgeToken == "" { | ||
| return fmt.Errorf("FULLSEND_FORGE_TOKEN is required") | ||
| } | ||
|
|
||
| if projectPath == "" { | ||
| projectPath = os.Getenv("CI_PROJECT_PATH") | ||
| } | ||
| if projectPath == "" { | ||
| return fmt.Errorf("--project or CI_PROJECT_PATH is required") | ||
| } | ||
|
|
||
| slashCommandsOnly := pollModeFlag == "fast" || os.Getenv("FULLSEND_POLL_MODE") == "fast" | ||
|
|
||
| // The GitLab client is not yet implemented (Phase 1). | ||
| // This command will be fully wired when the GitLab forge | ||
| // client provides a type satisfying poll.GitLabClient. | ||
| _ = forgeToken | ||
| _ = gitlabURL | ||
|
|
||
| var botUserID int | ||
| // botUserID will be resolved via client.GetAuthenticatedUser | ||
| // once the GitLab client is available. | ||
|
|
||
| opts := poll.Options{ | ||
| SlashCommandsOnly: slashCommandsOnly, | ||
| BotUserID: botUserID, | ||
| OutputPath: outputPath, | ||
| GitLabURL: gitlabURL, | ||
| } | ||
|
|
||
| // TODO(phase1): Replace nil client and router with real | ||
| // implementations once the GitLab forge client exists. | ||
| poller := poll.New(nil, nil, projectPath, opts) | ||
| return poller.Run(cmd.Context()) | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringVar(&forgeFlag, "forge", "", "Forge platform (required: gitlab)") | ||
| _ = cmd.MarkFlagRequired("forge") | ||
| cmd.Flags().StringVar(&projectPath, "project", "", "GitLab project path (default: $CI_PROJECT_PATH)") | ||
| cmd.Flags().StringVar(&gitlabURL, "gitlab-url", "https://gitlab.com", "GitLab instance URL") | ||
| cmd.Flags().StringVar(&outputPath, "output", "", "Path to write dispatches JSON") | ||
| cmd.Flags().StringVar(&pollModeFlag, "poll-mode", "", "Poll mode: fast (slash commands only) or full") | ||
|
|
||
| cmd.Hidden = true | ||
| cmd.AddCommand(newPollGenerateChildPipelineCmd()) | ||
| return cmd | ||
| } | ||
|
|
||
| func newPollGenerateChildPipelineCmd() *cobra.Command { | ||
| var ( | ||
| dispatchesPath string | ||
| outputPath string | ||
| ) | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "generate-child-pipeline", | ||
| Short: "Generate child pipeline YAML from dispatches JSON", | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| return poll.GenerateChildPipelineFromFile(dispatchesPath, outputPath) | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringVar(&dispatchesPath, "dispatches", "dispatches.json", "Path to dispatches JSON file") | ||
| cmd.Flags().StringVar(&outputPath, "output", "child-pipeline.yml", "Path to write child pipeline YAML") | ||
|
|
||
| return cmd | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package dispatch | ||
|
ggallen marked this conversation as resolved.
ggallen marked this conversation as resolved.
ggallen marked this conversation as resolved.
|
||
|
|
||
| // NormalizedEvent is the forge-neutral routing input for dispatch and | ||
| // harness CEL trigger evaluation. See docs/normative/normalized-event/v1/. | ||
|
ggallen marked this conversation as resolved.
|
||
| // This type intentionally uses plain strings (not typed enums) to keep | ||
| // the dispatch package free of normevent dependencies; the poll layer | ||
| // is the only producer, and child pipelines consume JSON. | ||
| type NormalizedEvent struct { | ||
|
ggallen marked this conversation as resolved.
|
||
| Repo string `json:"repo"` | ||
| Entity Entity `json:"entity"` | ||
| Transition Transition `json:"transition"` | ||
| Actor Actor `json:"actor"` | ||
| State State `json:"state"` | ||
| Source Source `json:"source"` | ||
| } | ||
|
|
||
| // Entity identifies the work item or change proposal the event acts on. | ||
| type Entity struct { | ||
| Kind string `json:"kind"` | ||
| ID int `json:"id"` | ||
| URL string `json:"url"` | ||
| Key string `json:"key,omitempty"` | ||
| LinkedChangeProposal *LinkedChangeProposal `json:"linked_change_proposal,omitempty"` | ||
| } | ||
|
|
||
| // LinkedChangeProposal links a work_item entity to its associated change proposal. | ||
| type LinkedChangeProposal struct { | ||
| ID int `json:"id"` | ||
| URL string `json:"url"` | ||
| } | ||
|
|
||
| // Transition describes the lifecycle event that occurred. | ||
| type Transition struct { | ||
| Kind string `json:"kind"` | ||
| Label *TransitionLabel `json:"label,omitempty"` | ||
| Comment *TransitionComment `json:"comment,omitempty"` | ||
| Review *TransitionReview `json:"review,omitempty"` | ||
| } | ||
|
|
||
| // TransitionLabel carries label change details (kind == "label_changed"). | ||
| type TransitionLabel struct { | ||
| Name string `json:"name"` | ||
| Action string `json:"action"` | ||
| } | ||
|
|
||
| // TransitionComment carries comment details (kind == "comment_added"). | ||
| type TransitionComment struct { | ||
| Command string `json:"command,omitempty"` | ||
| Body string `json:"body"` | ||
| Instruction string `json:"instruction,omitempty"` | ||
| } | ||
|
|
||
| // TransitionReview carries review details (kind == "review_submitted"). | ||
| type TransitionReview struct { | ||
| State string `json:"state"` | ||
| ReviewerID string `json:"reviewer_id"` | ||
| } | ||
|
|
||
| // Actor identifies who triggered the event. | ||
| type Actor struct { | ||
| ID string `json:"id"` | ||
| Kind string `json:"kind"` | ||
| Role string `json:"role"` | ||
| IsEntityAuthor bool `json:"is_entity_author"` | ||
| } | ||
|
|
||
| // State captures the entity's state at event time. | ||
| // ChangeProposal is nil when MR metadata is unavailable (e.g., | ||
| // fast-poll mode or failed project-path resolution). Routers MUST | ||
| // treat nil as "unknown" and deny fork-sensitive stages by default. | ||
| type State struct { | ||
| Labels []string `json:"labels"` | ||
| ChangeProposal *ChangeProposalState `json:"change_proposal,omitempty"` | ||
| } | ||
|
|
||
| // ChangeProposalState carries MR/PR metadata needed by stages. | ||
| type ChangeProposalState struct { | ||
| ID int `json:"id"` | ||
| HeadRepo string `json:"head_repo"` | ||
| BaseRepo string `json:"base_repo"` | ||
| HeadRef string `json:"head_ref"` | ||
| BaseRef string `json:"base_ref"` | ||
| HeadSHA string `json:"head_sha,omitempty"` | ||
| AuthorID string `json:"author_id"` | ||
| IsFork bool `json:"is_fork"` | ||
| } | ||
|
|
||
| // Source records event provenance. | ||
| type Source struct { | ||
| System string `json:"system"` | ||
| RawType string `json:"raw_type"` | ||
| RawAction string `json:"raw_action,omitempty"` | ||
| } | ||
|
|
||
| // EventRouter routes a NormalizedEvent to zero or more stage names. | ||
| type EventRouter interface { | ||
| Route(event *NormalizedEvent) ([]string, error) | ||
| } | ||
|
ggallen marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| // Package poll implements the GitLab cron-polling event dispatch loop. | ||
|
ggallen marked this conversation as resolved.
ggallen marked this conversation as resolved.
|
||
| // It discovers events from the GitLab API, converts them to | ||
| // NormalizedEvents, routes them through the dispatch core, and | ||
| // triggers child pipelines for matched stages. | ||
| package poll | ||
|
|
||
| import ( | ||
| "context" | ||
| "time" | ||
| ) | ||
|
|
||
| // GitLabClient defines the GitLab API surface the poller requires. | ||
|
ggallen marked this conversation as resolved.
|
||
| // This interface is separate from forge.Client because the poller | ||
| // needs GitLab-specific methods (label events, project events) that | ||
| // are not part of the forge-neutral abstraction. Phase 1 wiring will | ||
| // provide a concrete type that satisfies this interface, backed by | ||
| // the forge.Client credential and HTTP plumbing. | ||
| // | ||
| // All List* methods MUST exhaust pagination (per_page=100, follow | ||
| // x-next-page) and return the complete result set. Returning only page 1 | ||
| // (GitLab default: 20 items) causes silent event loss. | ||
| type GitLabClient interface { | ||
| ListIssuesUpdatedSince(ctx context.Context, owner, repo string, since time.Time) ([]Issue, error) | ||
| ListMergeRequestsUpdatedSince(ctx context.Context, owner, repo string, since time.Time) ([]MergeRequest, error) | ||
|
ggallen marked this conversation as resolved.
|
||
| // ListProjectEvents returns events matching targetType (use lowercase | ||
| // "note" for the request param). The after parameter is date-only | ||
| // (ISO 8601 date, exclusive): implementations must widen to at least | ||
| // since.AddDate(0,0,-1) and apply client-side timestamp filtering. | ||
| ListProjectEvents(ctx context.Context, owner, repo string, targetType string, after time.Time) ([]ProjectEvent, error) | ||
| // ListIssueNotes MUST return notes in ascending created_at order. | ||
| ListIssueNotes(ctx context.Context, owner, repo string, issueIID int) ([]Note, error) | ||
| ListMergeRequestNotes(ctx context.Context, owner, repo string, mrIID int) ([]Note, error) | ||
| // ListResourceLabelEvents MUST return events in ascending ID order | ||
| // (the poller iterates in reverse to find the most recent "add"). | ||
| ListResourceLabelEvents(ctx context.Context, owner, repo string, issueIID int) ([]ResourceLabelEvent, error) | ||
| GetCIVariable(ctx context.Context, owner, repo, name string) (string, error) | ||
| // UpdateCIVariable creates or updates a CI variable. GitLab CI/CD | ||
| // variable values are capped at 10,000 characters. | ||
| UpdateCIVariable(ctx context.Context, owner, repo, name, value string, protected bool) error | ||
| GetAuthenticatedUser(ctx context.Context) (string, error) | ||
| // CreateNoteAwardEmoji adds an emoji reaction. noteableType must be | ||
| // "Issue" or "MergeRequest" to select the correct API endpoint. | ||
| CreateNoteAwardEmoji(ctx context.Context, owner, repo string, noteableType string, noteableIID, noteID int, emoji string) error | ||
| GetIssue(ctx context.Context, owner, repo string, issueIID int) (*Issue, error) | ||
| // GetMemberAccessLevel returns the access level for a project member. | ||
| // Implementations MUST use the /members/all/:user_id endpoint to | ||
| // include inherited (group-level) membership, not /members/:user_id | ||
| // which only returns direct members. | ||
| GetMemberAccessLevel(ctx context.Context, owner, repo string, userID int) (int, error) | ||
| GetProjectPath(ctx context.Context, projectID int) (string, error) | ||
| } | ||
|
ggallen marked this conversation as resolved.
|
||
|
|
||
| // Issue represents a GitLab issue as returned by the API. | ||
| // Author is a nested object in the GitLab v4 response. | ||
| type Issue struct { | ||
| IID int `json:"iid"` | ||
| Title string `json:"title"` | ||
| State string `json:"state"` | ||
| Labels []string `json:"labels"` | ||
| Author UserRef `json:"author"` | ||
| UpdatedAt time.Time `json:"updated_at"` | ||
| } | ||
|
|
||
| // MergeRequest represents a GitLab merge request as returned by the API. | ||
| // Fields are derived from nested API objects (author, merge_user); | ||
| // GitLab does not expose flat author_id/merged_by_id fields on MRs. | ||
| type MergeRequest struct { | ||
| IID int `json:"iid"` | ||
| Title string `json:"title"` | ||
| State string `json:"state"` | ||
| Labels []string `json:"labels"` | ||
| SourceProjectID int `json:"source_project_id"` | ||
| TargetProjectID int `json:"target_project_id"` | ||
| SourceBranch string `json:"source_branch"` | ||
| TargetBranch string `json:"target_branch"` | ||
| Author UserRef `json:"author"` | ||
| MergeUser UserRef `json:"merge_user"` | ||
| MergedBy UserRef `json:"merged_by"` | ||
| MergedAt time.Time `json:"merged_at"` | ||
| UpdatedAt time.Time `json:"updated_at"` | ||
| } | ||
|
|
||
| // Note represents a GitLab note (comment) on an issue or MR. | ||
| type Note struct { | ||
| ID int `json:"id"` | ||
| Body string `json:"body"` | ||
| Author UserRef `json:"author"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| // UserRef is a minimal user reference from the GitLab API. | ||
| // The Bot field is not present in all API responses (notably the | ||
| // Notes API author object omits it). Use isBotEvent() for reliable | ||
| // bot detection, which combines the API field, botUserID, and | ||
| // username-pattern heuristics. | ||
| type UserRef struct { | ||
| ID int `json:"id"` | ||
| Username string `json:"username"` | ||
| Bot bool `json:"bot"` | ||
|
ggallen marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // ProjectEvent represents an event from the GitLab Events API. | ||
| type ProjectEvent struct { | ||
| ID int `json:"id"` | ||
| Author UserRef `json:"author"` | ||
| Note EventNote `json:"note"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| // EventNote is the embedded note object in a project event. | ||
| type EventNote struct { | ||
| ID int `json:"id"` | ||
| NoteableType string `json:"noteable_type"` | ||
| NoteableIID int `json:"noteable_iid"` | ||
| Body string `json:"body"` | ||
| } | ||
|
|
||
| // ResourceLabelEvent represents a label change event from the GitLab API. | ||
| type ResourceLabelEvent struct { | ||
| ID int `json:"id"` | ||
| Action string `json:"action"` | ||
| Label struct { | ||
| Name string `json:"name"` | ||
| } `json:"label"` | ||
| User UserRef `json:"user"` | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.