-
Notifications
You must be signed in to change notification settings - Fork 65
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’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ggallen
wants to merge
1
commit into
main
Choose a base branch
from
pr-4099
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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,96 @@ | ||
| 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()) | ||
| }, | ||
| } | ||
|
|
||
| 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.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,92 @@ | ||
| package dispatch | ||
|
|
||
| // NormalizedEvent is the forge-neutral routing input for dispatch and | ||
| // harness CEL trigger evaluation. See docs/normative/normalized-event/v1/. | ||
| type NormalizedEvent struct { | ||
| 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. | ||
| 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) | ||
| } |
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 poll implements the GitLab cron-polling event dispatch loop. | ||
| // 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. | ||
| // The interface is satisfied by the GitLab forge client (Phase 1). | ||
| 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) | ||
| ListProjectEvents(ctx context.Context, owner, repo string, targetType string, after time.Time) ([]ProjectEvent, error) | ||
| ListIssueNotes(ctx context.Context, owner, repo string, issueIID int) ([]Note, error) | ||
| ListMergeRequestNotes(ctx context.Context, owner, repo string, mrIID int) ([]Note, error) | ||
| ListResourceLabelEvents(ctx context.Context, owner, repo string, issueIID int) ([]ResourceLabelEvent, error) | ||
| GetCIVariable(ctx context.Context, owner, repo, name string) (string, error) | ||
| UpdateCIVariable(ctx context.Context, owner, repo, name, value string, protected bool) error | ||
| GetAuthenticatedUser(ctx context.Context) (string, error) | ||
| CreateNoteAwardEmoji(ctx context.Context, owner, repo string, noteableIID, noteID int, emoji string) error | ||
| GetIssue(ctx context.Context, owner, repo string, issueIID int) (*Issue, error) | ||
| 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. | ||
| type Issue struct { | ||
| IID int `json:"iid"` | ||
| Title string `json:"title"` | ||
| State string `json:"state"` | ||
| Labels []string `json:"labels"` | ||
| AuthorID int `json:"author_id"` | ||
| UpdatedAt time.Time `json:"updated_at"` | ||
| } | ||
|
|
||
| // MergeRequest represents a GitLab merge request as returned by the API. | ||
| 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"` | ||
| AuthorID int `json:"author_id"` | ||
| MergedByID int `json:"merged_by_id"` | ||
| 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. | ||
| type UserRef struct { | ||
| ID int `json:"id"` | ||
| Username string `json:"username"` | ||
| Bot bool `json:"bot"` | ||
| } | ||
|
|
||
| // 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.