Skip to content
Open
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
96 changes: 96 additions & 0 deletions internal/cli/poll.go
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())
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.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 @@ -52,6 +52,7 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newPostReviewCmd())
cmd.AddCommand(newPostCommentCmd())
cmd.AddCommand(newReconcileStatusCmd())
cmd.AddCommand(newPollCmd())
return cmd
}

Expand Down
92 changes: 92 additions & 0 deletions internal/dispatch/event.go
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)
}
97 changes: 97 additions & 0 deletions internal/poll/client.go
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)
}
Comment thread
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"`
}
Loading
Loading