Skip to content
Closed
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
95 changes: 95 additions & 0 deletions internal/cli/poll.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 on lines +60 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Poll command panics 🐞 Bug ☼ Reliability

newPollCmd constructs poll.New(nil, nil, ...) and calls Run(), which dereferences p.client
in readWatermark and will panic. Even if the nil client were fixed, a nil router causes Run() to
treat all events as “no stages matched” and still advance the watermark, silently dropping
dispatches.
Agent Prompt
## Issue description
The `fullsend poll` command is registered and runnable, but it constructs a poller with a nil `GitLabClient` and nil `EventRouter`, which will crash on first API access. Additionally, nil routing currently results in advancing the watermark without dispatching, which can lose events.

## Issue Context
This PR adds the command to the root CLI, making it easy to invoke in CI/cron as described in the plan docs.

## Fix Focus Areas
- Ensure `fullsend poll` fails fast with a clear error until a real GitLab client/router is available, OR fully wire real implementations.
- Add non-nil validation in `poll.New(...)` or `(*Poller).Run(...)` (return error) so misconfiguration can’t panic or silently advance the watermark.

- internal/cli/poll.go[21-64]
- internal/cli/root.go[42-56]
- internal/poll/poll.go[48-100]
- internal/poll/state.go[13-27]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

},
}

cmd.Flags().StringVar(&forgeFlag, "forge", "", "Forge platform (required: gitlab)")
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)
}
96 changes: 96 additions & 0 deletions internal/poll/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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)
}

// 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"`
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