Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions internal/cli/poll.go
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
Comment thread
ggallen marked this conversation as resolved.
// 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())
Comment thread
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
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newPostReviewCmd())
cmd.AddCommand(newPostCommentCmd())
cmd.AddCommand(newReconcileStatusCmd())
cmd.AddCommand(newPollCmd())
return cmd
}

Expand Down
98 changes: 98 additions & 0 deletions internal/dispatch/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package dispatch
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
Comment thread
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/.
Comment thread
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 {
Comment thread
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)
}
Comment thread
ggallen marked this conversation as resolved.
126 changes: 126 additions & 0 deletions internal/poll/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Package poll implements the GitLab cron-polling event dispatch loop.
Comment thread
ggallen marked this conversation as resolved.
Comment thread
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.
Comment thread
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)
Comment thread
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)
}
Comment thread
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"`
Comment thread
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"`
}
Loading
Loading