Skip to content

feat(poll): implement Phase 2 cron poller for GitLab event dispatch#4099

Closed
ggallen wants to merge 1 commit into
mainfrom
worktree-phase2-cron-poller
Closed

feat(poll): implement Phase 2 cron poller for GitLab event dispatch#4099
ggallen wants to merge 1 commit into
mainfrom
worktree-phase2-cron-poller

Conversation

@ggallen

@ggallen ggallen commented Jul 11, 2026

Copy link
Copy Markdown
Member

Summary

  • Implements the cron-based poller (ADR 0067 Phase 2) that discovers GitLab events via API polling, converts them to NormalizedEvents, routes through the dispatch core, and triggers child pipelines
  • Adds internal/poll/ package with event discovery, bot filtering, deduplication, label state diffing, watermark management, and child pipeline YAML generation
  • Adds internal/dispatch/event.go with NormalizedEvent type and EventRouter interface
  • Adds fullsend poll CLI command (internal/cli/poll.go)

Test plan

  • All 83 tests pass (go test ./internal/poll/ -count=1)
  • Coverage: 91.8% on internal/poll/ (target >85%)
  • go build ./... clean
  • go vet clean
  • Pre-commit hooks pass (gofmt, secrets, etc.)

🤖 Generated with Claude Code

Implements the cron-based poller (ADR 0067 Phase 2) that discovers
GitLab events via API polling, converts them to NormalizedEvents,
routes through the dispatch core, and triggers child pipelines.

New packages and files:
- internal/dispatch/event.go: NormalizedEvent type and EventRouter interface
- internal/poll/: complete poller with event discovery, bot filtering,
  deduplication, label state diffing, watermark management, and
  child pipeline YAML generation
- internal/cli/poll.go: `fullsend poll` CLI command

Test coverage: 91.8% on internal/poll/.

Signed-off-by: Greg Allen <greg@fullsend.ai>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Greg Allen <gallen@redhat.com>
@ggallen ggallen requested a review from a team as a code owner July 11, 2026 11:23
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 11, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 11:24 AM UTC · Completed 11:32 AM UTC
Commit: f0ccd41 · View workflow run →

@ggallen

ggallen commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Recreating PR with renamed branch pr-4099

@ggallen ggallen closed this Jul 11, 2026
@ggallen ggallen deleted the worktree-phase2-cron-poller branch July 11, 2026 11:24
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add Phase 2 cron poller to route GitLab events into dispatch + child pipelines

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add cron-style GitLab poller that discovers events, routes stages, and emits dispatch records.
• Introduce forge-neutral NormalizedEvent + EventRouter contract for dispatch routing.
• Add fullsend poll CLI (plus child-pipeline YAML generator) and comprehensive poller tests.
Diagram

graph TD
  cli["fullsend poll CLI"] --> poller(["Poller"])
  poller --> gl["GitLab API"]
  poller --> router(["EventRouter"])
  poller --> civars[("CI Variables")]
  poller --> out["dispatches.json"] --> gen["Child pipeline generator"] --> yml["child-pipeline.yml"]

  subgraph Legend
    direction LR
    _svc(["Service/Component"]) ~~~ _db[("Persistent state")] ~~~ _file["File artifact"]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. GitLab webhooks instead of polling
  • ➕ Lower latency and fewer API calls than periodic polling
  • ➕ Simpler dedup/watermark logic (events are pushed once)
  • ➖ Requires webhook infrastructure and secure inbound connectivity
  • ➖ Harder to run purely as a CI cron job; more operational surface area
2. Persist watermark/label state in a dedicated store (Redis/DB/object storage)
  • ➕ More robust than CI variable storage (size limits, concurrent writers, auditability)
  • ➕ Easier to support multiple pollers / sharded polling
  • ➖ Adds infrastructure dependency and credentials management
  • ➖ Overkill for early Phase 2 if CI variables are sufficient
3. Use GitLab Events API exclusively (including labels)
  • ➕ Single lightweight API surface for discovery; potentially fewer per-issue calls
  • ➖ May not provide enough structured data for label-diffing/actor resolution without extra calls
  • ➖ Less control over completeness vs per-resource polling

Recommendation: Given the stated ADR Phase 2 goal (cron-driven CI poller), the current approach is reasonable: it explicitly models the required GitLab API surface, normalizes events into a forge-neutral shape, and persists minimal state via CI variables. The main follow-up to consider is moving state out of CI variables if concurrency/size constraints appear, and wiring the CLI to the real GitLab client/router once Phase 1 is available.

Files changed (16) +3951 / -0

Enhancement (10) +1274 / -0
poll.goAdd 'fullsend poll' command and child-pipeline YAML generator subcommand +95/-0

Add 'fullsend poll' command and child-pipeline YAML generator subcommand

• Introduces a new Cobra 'poll' command that configures and runs the poller (currently stubbed pending Phase 1 GitLab client/router wiring). Adds 'generate-child-pipeline' subcommand to convert dispatch JSON into GitLab child pipeline YAML.

internal/cli/poll.go

root.goRegister poll command in CLI root +1/-0

Register poll command in CLI root

• Adds 'newPollCmd()' to the root CLI so the poller is accessible via 'fullsend poll'.

internal/cli/root.go

event.goIntroduce forge-neutral NormalizedEvent and routing interface +92/-0

Introduce forge-neutral NormalizedEvent and routing interface

• Defines the 'dispatch.NormalizedEvent' schema used for trigger evaluation/routing and an 'EventRouter' interface to map events to stage names. Adds structured subtypes for entity, transition details (labels/comments/reviews), actor, state, and source provenance.

internal/dispatch/event.go

client.goDefine GitLabClient interface and poller-side API models +96/-0

Define GitLabClient interface and poller-side API models

• Adds 'GitLabClient' interface capturing the GitLab API calls needed for polling (issues/MRs/notes/events/label events, CI variables, user lookup, emoji reaction, membership access, project path resolution). Defines lightweight DTOs for issues, merge requests, notes, project events, and label events.

internal/poll/client.go

convert.goConvert discovered events into NormalizedEvent for routing +267/-0

Convert discovered events into NormalizedEvent for routing

• Implements 'toNormalizedEvent' to map internal 'RoutableEvent' into 'dispatch.NormalizedEvent', including entity URL/kind, transition details, slash-command extraction, actor resolution (including label-author lookup), and MR change-proposal state enrichment. Adds helper functions for raw type mapping, URL building, role normalization, and best-effort entity-author detection.

internal/poll/convert.go

dispatch.goCreate dispatch records and generate GitLab child pipeline YAML +117/-0

Create dispatch records and generate GitLab child pipeline YAML

• Implements dispatch accumulation and JSON output, builds base64-encoded event payloads, and generates per-dispatch child pipeline YAML jobs with required variables. Adds a helper to generate YAML from a dispatches JSON file for the CLI subcommand.

internal/poll/dispatch.go

events.goImplement event discovery, bot filtering, and deduplication +221/-0

Implement event discovery, bot filtering, and deduplication

• Adds full discovery mode (issues + notes + MR notes + merge events) and fast mode (Events API for /fs-* notes only). Implements bot filtering with an exception for enrolled bot “changes-requested” markers, fork detection helper, routable label filtering, and deduplication by event key.

internal/poll/events.go

poll.goAdd Poller core loop with routing, dispatching, and watermark advancement +188/-0

Add Poller core loop with routing, dispatching, and watermark advancement

• Introduces 'Poller' and its 'Run' method to execute a single polling cycle: read watermark, discover events (fast/full), filter/dedup, normalize, route via 'EventRouter', emit dispatch records, react to slash command notes, update watermark with failure/skipped safeguards, and persist label state with rollback on failed label dispatches.

internal/poll/poll.go

state.goPersist watermark and label state via GitLab CI variables +138/-0

Persist watermark and label state via GitLab CI variables

• Implements reading/updating per-mode watermark CI variables with first-run defaulting. Adds label-state diffing to detect newly added routable labels, pruning of closed issues, persistence of label state back to CI variables, and helper utilities for issue-closed checks and set conversion.

internal/poll/state.go

types.goAdd poller options and core data types (events, dispatches, label state) +59/-0

Add poller options and core data types (events, dispatches, label state)

• Defines 'Options', 'RoutableEvent' (including deduplication key semantics), 'LabelState', 'Dispatch' record schema, and 'LabelAuthor' helper type used across the poller implementation.

internal/poll/types.go

Tests (6) +2677 / -0
convert_test.goUnit tests for NormalizedEvent conversion and helpers +600/-0

Unit tests for NormalizedEvent conversion and helpers

• Covers conversion behavior for issue notes, label changes, MR notes, and MR merge events; validates actor/bot handling and error behavior for unresolvable actors. Adds focused tests for helper mappings (event type translation, raw type, entity kind/URL) and label-author/role resolution.

internal/poll/convert_test.go

dispatch_test.goTests for dispatch JSON writing, payload construction, and YAML generation +445/-0

Tests for dispatch JSON writing, payload construction, and YAML generation

• Verifies dispatch accumulation, stable JSON output (including empty output behavior), correct base64-encoded JSON payloads, and correct YAML structure for single/multiple dispatches. Tests file-based YAML generation error cases (missing input, invalid JSON) and empty-dispatch behavior.

internal/poll/dispatch_test.go

events_test.goTests for event discovery modes and filtering behaviors +579/-0

Tests for event discovery modes and filtering behaviors

• Exercises discovery for issue notes, label events, MR merges, MR notes, and skip logic for old notes. Covers error-handling paths (note fetch failure restores label state; MR list failure is non-fatal), slash-command discovery, bot detection/filtering, fork detection defaults, routable-label filtering, and deduplication.

internal/poll/events_test.go

mock_test.goAdd configurable mock GitLabClient for poller tests +173/-0

Add configurable mock GitLabClient for poller tests

• Implements a deterministic 'mockClient' that satisfies 'GitLabClient', including CI variable storage, injected errors, and call recording (emoji reactions, updated variables). Enables unit tests for discovery, conversion, state management, and dispatch behaviors without a real GitLab client.

internal/poll/mock_test.go

poll_test.goIntegration-style unit tests for Poller run loop +456/-0

Integration-style unit tests for Poller run loop

• Validates watermark handling for empty polls and fast mode, routing to single/multiple stages, emoji reaction behavior for slash commands, behavior when router is nil or errors, conversion failures skipping events, and watermark not advancing when all events fail. Covers label state persistence and rollback semantics on label dispatch failures.

internal/poll/poll_test.go

state_test.goTests for watermark and label-state diff/persistence logic +424/-0

Tests for watermark and label-state diff/persistence logic

• Covers watermark reads (stored/default/error cases), mode-specific watermark variable naming, and RFC3339 persistence. Extensively tests label-state behavior: new label detection, corrupt state fallback, pruning closed issues only, previous-state snapshotting, persistence roundtrip, and closed-issue detection semantics.

internal/poll/state_test.go

@fullsend-ai-retro

fullsend-ai-retro Bot commented Jul 11, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 11:26 AM UTC · Completed 11:37 AM UTC
Commit: f0ccd41 · View workflow run →

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (2) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 54 rules

Grey Divider


Action required

1. Poller bypasses forge.Client 📘 Rule violation ⌂ Architecture
Description
The new poller introduces a parallel GitLabClient interface and injects it into Poller, instead
of routing forge operations through internal/forge.Client as required. This undermines the
project’s forge-agnostic boundary and risks duplicated/fragmented forge behavior across packages.
Code

internal/poll/poll.go[R13-35]

+// Poller discovers GitLab events and dispatches agent stages.
+type Poller struct {
+	client      GitLabClient
+	router      dispatch.EventRouter
+	projectPath string
+	owner       string
+	repo        string
+	botUserID   int
+	gitlabURL   string
+	opts        Options
+	dispatches  []Dispatch
+}
+
+// New creates a Poller for the given project.
+func New(client GitLabClient, router dispatch.EventRouter, projectPath string, opts Options) *Poller {
+	owner, repo := splitOwnerRepo(projectPath)
+	gitlabURL := opts.GitLabURL
+	if gitlabURL == "" {
+		gitlabURL = "https://gitlab.com"
+	}
+	return &Poller{
+		client:      client,
+		router:      router,
Relevance

⭐⭐ Medium

No historical evidence on enforcing forge.Client boundary vs new GitLabClient interface;
internal/poll has no prior reviews.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1062052 requires forge operations to use forge.Client. The PR adds a new
GitLabClient interface (internal/poll/client.go) and makes Poller depend on it
(internal/poll/poll.go), including calling forge actions like CreateNoteAwardEmoji, rather than
depending on forge.Client.

Rule 1062052: Route all git forge operations through forge.Client
internal/poll/client.go[12-28]
internal/poll/poll.go[13-15]
internal/poll/poll.go[112-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new poller code performs forge operations via a new `poll.GitLabClient` interface instead of using the required `internal/forge.Client` interface.

## Issue Context
Compliance requires all forge operations to flow through `forge.Client` to keep the codebase forge-agnostic and prevent new direct forge-specific API surfaces from spreading outside `internal/forge`.

## Fix Focus Areas
- internal/poll/client.go[12-28]
- internal/poll/poll.go[13-43]

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


2. Poll command panics 🐞 Bug ☼ Reliability
Description
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.
Code

internal/cli/poll.go[R60-63]

+			// 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())
Relevance

⭐⭐ Medium

No historical evidence found for nil-dependency panic fixes in this repo’s reviews; can’t predict
team response.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The CLI constructs the poller with nil dependencies, Run() immediately calls into watermark
reading which dereferences p.client, and the routing block is skipped entirely when p.router is
nil (but watermark advancement still occurs). The root command registers poll, so this is
reachable.

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

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


3. Label state incomplete 🐞 Bug ≡ Correctness
Description
discoverAllEvents emits issue_label events with Labels set to only the newly-added label, but
toNormalizedEvent copies event.Labels into NormalizedEvent.state.labels. This makes
state.labels incorrect for label_changed events and breaks routing logic that expects the
current label set.
Code

internal/poll/events.go[R57-66]

+		if added, ok := newLabels[issue.IID]; ok {
+			for _, label := range added {
+				events = append(events, RoutableEvent{
+					Type:      "issue_label",
+					IID:       issue.IID,
+					UpdatedAt: issue.UpdatedAt,
+					Labels:    []string{label},
+				})
+			}
+		}
Relevance

⭐⭐ Medium

No historical evidence on normalized-event label state expectations; internal/poll and related
routing patterns have no prior review history.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The poller constructs issue_label events with a single label in Labels, and conversion maps that
slice directly into state.labels while also using index 0 as the changed label name. The project’s
own plan and normative schema docs explicitly describe state.labels as the current label set.

internal/poll/events.go[25-66]
internal/poll/convert.go[14-42]
docs/plans/gitlab-cron-polling-implementation.md[730-739]
docs/normative/normalized-event/v1/README.md[56-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
For `issue_label` events, `RoutableEvent.Labels` is being used for two different meanings:
1) the label that changed (used as `transition.label.name`), and
2) the entity’s current label set (should map to `state.labels`).
Currently, only the changed label is sent, so `state.labels` becomes incomplete.

## Issue Context
The normative NormalizedEvent docs and the GitLab poller plan both state that `state.labels` is the *current label set* at event time.

## Fix Focus Areas
- Change `RoutableEvent` to carry both the changed label (e.g. `LabelName` / `ChangedLabel`) and the full `Labels` state.
- Update `discoverAllEvents` to set `Labels` to the issue’s full label set and set the changed-label field separately.
- Update `toNormalizedEvent` to:
 - set `state.labels` from the full labels slice, and
 - set `transition.label.name` from the changed-label field.

- internal/poll/events.go[57-66]
- internal/poll/types.go[18-32]
- internal/poll/convert.go[14-69]
- docs/plans/gitlab-cron-polling-implementation.md[730-739]
- docs/normative/normalized-event/v1/README.md[56-63]

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



Remediation recommended

4. Unvalidated stage in YAML 🐞 Bug ☼ Reliability
Description
generateChildPipelineYAML interpolates Dispatch.Stage into the generated YAML include path
without validation, so invalid stage strings can generate malformed child pipeline YAML or
unexpected include references. The generate-child-pipeline CLI subcommand reads dispatches from an
arbitrary file, making this easy to trigger.
Code

internal/poll/dispatch.go[R85-96]

+func generateChildPipelineYAML(dispatches []Dispatch) string {
+	var buf bytes.Buffer
+	for i, d := range dispatches {
+		fmt.Fprintf(&buf, "agent-%d:\n", i)
+		fmt.Fprintf(&buf, "  trigger:\n")
+		fmt.Fprintf(&buf, "    include: .gitlab/ci/fullsend-%s.yml\n", d.Stage)
+		fmt.Fprintf(&buf, "    strategy: depend\n")
+		fmt.Fprintf(&buf, "  variables:\n")
+		fmt.Fprintf(&buf, "    STAGE: %q\n", d.Stage)
+		fmt.Fprintf(&buf, "    EVENT_TYPE: %q\n", d.EventType)
+		fmt.Fprintf(&buf, "    EVENT_PAYLOAD_B64: %q\n", d.EventPayloadB64)
+		fmt.Fprintf(&buf, "    RESOURCE_KEY: %q\n", d.ResourceKey)
Relevance

⭐⭐ Medium

No historical evidence on validating YAML include paths/stage strings; internal/poll package has no
repo history.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The YAML generator uses the stage value directly inside the include path with %s, and the CLI
exposes a command that reads dispatches from a file and calls that generator.

internal/poll/dispatch.go[83-100]
internal/cli/poll.go[77-93]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Stage names are inserted directly into the generated YAML include path. If a stage contains unexpected characters (e.g., whitespace/newlines or path separators), the produced YAML can become invalid or reference unintended include paths.

## Issue Context
`GenerateChildPipelineFromFile` reads user-supplied JSON and generates YAML, so this should be robust against malformed inputs.

## Fix Focus Areas
- Validate `Dispatch.Stage` against a strict allowlist/pattern (e.g. `^[a-z0-9][a-z0-9-]*$`) before generating YAML; return an error on invalid values.
- (Optional) Consider emitting YAML using a YAML library to avoid hand-rolled formatting risks.

- internal/poll/dispatch.go[83-100]
- internal/cli/poll.go[77-93]

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


5. ResourceKey too specific 🐞 Bug ☼ Reliability
Description
dispatch() sets ResourceKey to "<eventType>-<iid>", so different event types for the same
issue/MR get different keys and can run concurrently even when they should share a per-entity lock.
The GitLab cron-poller plan shows RESOURCE_KEY being used for resource_group locking, implying a
stable per-entity key (e.g. mr-<iid>) is intended.
Code

internal/poll/dispatch.go[R40-47]

+	payload := buildEventPayload(event)
+	encoded := base64.StdEncoding.EncodeToString(payload)
+	return p.appendDispatch(Dispatch{
+		Stage:           stage,
+		EventType:       event.Type,
+		EventPayloadB64: encoded,
+		ResourceKey:     fmt.Sprintf("%s-%d", event.Type, event.IID),
+	})
Relevance

⭐⭐ Medium

No historical evidence: internal/poll is new here (no prior PRs/suggestions), so
locking/resource_key conventions unknown.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The implementation uses event.Type in ResourceKey, while the project’s GitLab CI plan
demonstrates RESOURCE_KEY as an MR-scoped value and shows it being used for resource_group
locking, which is per-resource rather than per-event-type.

internal/poll/dispatch.go[34-48]
docs/plans/gitlab-cron-polling-implementation.md[1262-1275]
docs/plans/gitlab-cron-polling-implementation.md[1494-1497]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ResourceKey` is currently derived from `event.Type`, which fragments locking for the same underlying entity (issue/MR) across event types.

## Issue Context
The planned GitLab CI templates use `RESOURCE_KEY` in `resource_group` to prevent concurrent runs against the same resource.

## Fix Focus Areas
- Derive `ResourceKey` from entity kind + IID (e.g., `issue-<iid>` / `mr-<iid>`) rather than event type.
- Ensure merge/comment/label events for the same MR/issue share the same key.

- internal/poll/dispatch.go[34-48]
- internal/poll/convert.go[206-229]
- docs/plans/gitlab-cron-polling-implementation.md[1262-1275]
- docs/plans/gitlab-cron-polling-implementation.md[1496-1497]

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


6. MR skip not atomic 🐞 Bug ☼ Reliability
Description
When MR note listing fails, discoverAllEvents logs that it is “skipping MR entirely” but may
already have appended an mr_event merge event for the same MR. This can lead to partial processing
and repeated merge-event dispatch attempts on subsequent polls.
Code

internal/poll/events.go[R94-114]

+	for _, mr := range mrs {
+		if !mr.MergedAt.IsZero() && mr.MergedAt.After(since) {
+			events = append(events, RoutableEvent{
+				Type:         "mr_event",
+				IID:          mr.IID,
+				UpdatedAt:    mr.MergedAt,
+				NoteAuthorID: mr.MergedByID,
+				IsBot:        mr.MergedBy.Bot,
+				MRSource:     mr.SourceProjectID,
+				MRTarget:     mr.TargetProjectID,
+			})
+		}
+
+		notes, err := p.client.ListMergeRequestNotes(ctx, owner, repo, mr.IID)
+		if err != nil {
+			log.Printf("list notes for MR %d: %v (skipping MR entirely)", mr.IID, err)
+			if minSkippedAt.IsZero() || mr.UpdatedAt.Before(minSkippedAt) {
+				minSkippedAt = mr.UpdatedAt
+			}
+			continue
+		}
Relevance

⭐⭐ Medium

No historical evidence for “atomic skip” event discovery behavior; internal/poll is new so
acceptance unclear.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code appends an mr_event before calling ListMergeRequestNotes, and on error it logs
“skipping MR entirely” and continues, leaving the already-appended merge event in the returned list.

internal/poll/events.go[94-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
MR discovery is not atomic: merge events can be appended before MR note retrieval succeeds, but on note retrieval failure the code indicates the MR is skipped.

## Issue Context
This impacts retry behavior and can cause duplicate merge dispatches when the notes endpoint is flaky.

## Fix Focus Areas
- Decide intended behavior:
 - either treat note retrieval failure as skipping *only* note events (and update log message), OR
 - truly skip the MR (don’t append `mr_event` until after successful note retrieval, or buffer events and only append on success).
- Ensure `minSkippedAt`/watermark behavior matches the chosen semantics.

- internal/poll/events.go[94-114]

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



Informational

7. newPollCmd missing unit tests 📘 Rule violation ▣ Testability
Description
The PR adds new CLI logic in internal/cli/poll.go (env/flag validation and poll-mode selection)
but does not add or update any corresponding tests in the internal/cli package. This violates the
requirement to provide tests for new or modified Go logic.
Code

internal/cli/poll.go[R12-64]

+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())
+		},
Relevance

⭐ Low

Similar “add tests for new CLI logic” suggestions were repeatedly rejected in internal/cli (e.g.,
PRs #2860, #372).

PR-#2860
PR-#372
PR-#1627

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1062049 requires tests for each new/modified non-test .go file. This PR adds the
new newPollCmd() implementation and wires it into the root command, but no _test.go changes
accompany this new CLI logic.

Rule 1062049: Require tests for new or modified Go logic
internal/cli/poll.go[12-64]
internal/cli/root.go[52-56]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`internal/cli/poll.go` introduces new CLI behavior without adding/updating tests in the same package.

## Issue Context
Compliance requires that new or modified non-test Go files have corresponding tests added or updated in the same change.

## Fix Focus Areas
- internal/cli/poll.go[12-64]
- internal/cli/root.go[52-56]

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


Grey Divider

Qodo Logo

Comment thread internal/poll/poll.go
Comment on lines +13 to +35
// Poller discovers GitLab events and dispatches agent stages.
type Poller struct {
client GitLabClient
router dispatch.EventRouter
projectPath string
owner string
repo string
botUserID int
gitlabURL string
opts Options
dispatches []Dispatch
}

// New creates a Poller for the given project.
func New(client GitLabClient, router dispatch.EventRouter, projectPath string, opts Options) *Poller {
owner, repo := splitOwnerRepo(projectPath)
gitlabURL := opts.GitLabURL
if gitlabURL == "" {
gitlabURL = "https://gitlab.com"
}
return &Poller{
client: client,
router: router,

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

1. poller bypasses forge.client 📘 Rule violation ⌂ Architecture

The new poller introduces a parallel GitLabClient interface and injects it into Poller, instead
of routing forge operations through internal/forge.Client as required. This undermines the
project’s forge-agnostic boundary and risks duplicated/fragmented forge behavior across packages.
Agent Prompt
## Issue description
The new poller code performs forge operations via a new `poll.GitLabClient` interface instead of using the required `internal/forge.Client` interface.

## Issue Context
Compliance requires all forge operations to flow through `forge.Client` to keep the codebase forge-agnostic and prevent new direct forge-specific API surfaces from spreading outside `internal/forge`.

## Fix Focus Areas
- internal/poll/client.go[12-28]
- internal/poll/poll.go[13-43]

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

Comment thread internal/cli/poll.go
Comment on lines +60 to +63
// 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())

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

Comment thread internal/poll/events.go
Comment on lines +57 to +66
if added, ok := newLabels[issue.IID]; ok {
for _, label := range added {
events = append(events, RoutableEvent{
Type: "issue_label",
IID: issue.IID,
UpdatedAt: issue.UpdatedAt,
Labels: []string{label},
})
}
}

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

4. Label state incomplete 🐞 Bug ≡ Correctness

discoverAllEvents emits issue_label events with Labels set to only the newly-added label, but
toNormalizedEvent copies event.Labels into NormalizedEvent.state.labels. This makes
state.labels incorrect for label_changed events and breaks routing logic that expects the
current label set.
Agent Prompt
## Issue description
For `issue_label` events, `RoutableEvent.Labels` is being used for two different meanings:
1) the label that changed (used as `transition.label.name`), and
2) the entity’s current label set (should map to `state.labels`).
Currently, only the changed label is sent, so `state.labels` becomes incomplete.

## Issue Context
The normative NormalizedEvent docs and the GitLab poller plan both state that `state.labels` is the *current label set* at event time.

## Fix Focus Areas
- Change `RoutableEvent` to carry both the changed label (e.g. `LabelName` / `ChangedLabel`) and the full `Labels` state.
- Update `discoverAllEvents` to set `Labels` to the issue’s full label set and set the changed-label field separately.
- Update `toNormalizedEvent` to:
  - set `state.labels` from the full labels slice, and
  - set `transition.label.name` from the changed-label field.

- internal/poll/events.go[57-66]
- internal/poll/types.go[18-32]
- internal/poll/convert.go[14-69]
- docs/plans/gitlab-cron-polling-implementation.md[730-739]
- docs/normative/normalized-event/v1/README.md[56-63]

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

Comment thread internal/poll/dispatch.go
Comment on lines +85 to +96
func generateChildPipelineYAML(dispatches []Dispatch) string {
var buf bytes.Buffer
for i, d := range dispatches {
fmt.Fprintf(&buf, "agent-%d:\n", i)
fmt.Fprintf(&buf, " trigger:\n")
fmt.Fprintf(&buf, " include: .gitlab/ci/fullsend-%s.yml\n", d.Stage)
fmt.Fprintf(&buf, " strategy: depend\n")
fmt.Fprintf(&buf, " variables:\n")
fmt.Fprintf(&buf, " STAGE: %q\n", d.Stage)
fmt.Fprintf(&buf, " EVENT_TYPE: %q\n", d.EventType)
fmt.Fprintf(&buf, " EVENT_PAYLOAD_B64: %q\n", d.EventPayloadB64)
fmt.Fprintf(&buf, " RESOURCE_KEY: %q\n", d.ResourceKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

5. Unvalidated stage in yaml 🐞 Bug ☼ Reliability

generateChildPipelineYAML interpolates Dispatch.Stage into the generated YAML include path
without validation, so invalid stage strings can generate malformed child pipeline YAML or
unexpected include references. The generate-child-pipeline CLI subcommand reads dispatches from an
arbitrary file, making this easy to trigger.
Agent Prompt
## Issue description
Stage names are inserted directly into the generated YAML include path. If a stage contains unexpected characters (e.g., whitespace/newlines or path separators), the produced YAML can become invalid or reference unintended include paths.

## Issue Context
`GenerateChildPipelineFromFile` reads user-supplied JSON and generates YAML, so this should be robust against malformed inputs.

## Fix Focus Areas
- Validate `Dispatch.Stage` against a strict allowlist/pattern (e.g. `^[a-z0-9][a-z0-9-]*$`) before generating YAML; return an error on invalid values.
- (Optional) Consider emitting YAML using a YAML library to avoid hand-rolled formatting risks.

- internal/poll/dispatch.go[83-100]
- internal/cli/poll.go[77-93]

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

Comment thread internal/poll/dispatch.go
Comment on lines +40 to +47
payload := buildEventPayload(event)
encoded := base64.StdEncoding.EncodeToString(payload)
return p.appendDispatch(Dispatch{
Stage: stage,
EventType: event.Type,
EventPayloadB64: encoded,
ResourceKey: fmt.Sprintf("%s-%d", event.Type, event.IID),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

6. Resourcekey too specific 🐞 Bug ☼ Reliability

dispatch() sets ResourceKey to "<eventType>-<iid>", so different event types for the same
issue/MR get different keys and can run concurrently even when they should share a per-entity lock.
The GitLab cron-poller plan shows RESOURCE_KEY being used for resource_group locking, implying a
stable per-entity key (e.g. mr-<iid>) is intended.
Agent Prompt
## Issue description
`ResourceKey` is currently derived from `event.Type`, which fragments locking for the same underlying entity (issue/MR) across event types.

## Issue Context
The planned GitLab CI templates use `RESOURCE_KEY` in `resource_group` to prevent concurrent runs against the same resource.

## Fix Focus Areas
- Derive `ResourceKey` from entity kind + IID (e.g., `issue-<iid>` / `mr-<iid>`) rather than event type.
- Ensure merge/comment/label events for the same MR/issue share the same key.

- internal/poll/dispatch.go[34-48]
- internal/poll/convert.go[206-229]
- docs/plans/gitlab-cron-polling-implementation.md[1262-1275]
- docs/plans/gitlab-cron-polling-implementation.md[1496-1497]

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

Comment thread internal/poll/events.go
Comment on lines +94 to +114
for _, mr := range mrs {
if !mr.MergedAt.IsZero() && mr.MergedAt.After(since) {
events = append(events, RoutableEvent{
Type: "mr_event",
IID: mr.IID,
UpdatedAt: mr.MergedAt,
NoteAuthorID: mr.MergedByID,
IsBot: mr.MergedBy.Bot,
MRSource: mr.SourceProjectID,
MRTarget: mr.TargetProjectID,
})
}

notes, err := p.client.ListMergeRequestNotes(ctx, owner, repo, mr.IID)
if err != nil {
log.Printf("list notes for MR %d: %v (skipping MR entirely)", mr.IID, err)
if minSkippedAt.IsZero() || mr.UpdatedAt.Before(minSkippedAt) {
minSkippedAt = mr.UpdatedAt
}
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

7. Mr skip not atomic 🐞 Bug ☼ Reliability

When MR note listing fails, discoverAllEvents logs that it is “skipping MR entirely” but may
already have appended an mr_event merge event for the same MR. This can lead to partial processing
and repeated merge-event dispatch attempts on subsequent polls.
Agent Prompt
## Issue description
MR discovery is not atomic: merge events can be appended before MR note retrieval succeeds, but on note retrieval failure the code indicates the MR is skipped.

## Issue Context
This impacts retry behavior and can cause duplicate merge dispatches when the notes endpoint is flaky.

## Fix Focus Areas
- Decide intended behavior:
  - either treat note retrieval failure as skipping *only* note events (and update log message), OR
  - truly skip the MR (don’t append `mr_event` until after successful note retrieval, or buffer events and only append on success).
- Ensure `minSkippedAt`/watermark behavior matches the chosen semantics.

- internal/poll/events.go[94-114]

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

@fullsend-ai-review

Copy link
Copy Markdown

Review skipped — this PR is already closed.

The /fs-review command only reviews open pull requests.

Posted by fullsend post-review check

@fullsend-ai-retro

Copy link
Copy Markdown

Retro: PR #4099 — Close-and-recreate waste cascade

PR: #4099 feat(poll): implement Phase 2 cron poller for GitLab event dispatch
Author: ggallen (human) · Lifecycle: 51 seconds (opened 11:23:29 UTC, closed 11:24:20 UTC)
Successor: PR #4100 — same commit (f0ccd41), renamed branch pr-4099

Timeline

Time (UTC) Event
11:23:29 PR #4099 opened (branch worktree-phase2-cron-poller)
11:23:41 Review dispatched for #4099 (run 29150892434)
11:24:12 Review agent posts "Started" on #4099 (pre-review passed — PR was still open)
11:24:20 Author closes #4099: "Recreating PR with renamed branch pr-4099"
11:24:31 Retro dispatched for closed #4099 (run 29150914308)
11:24:37 PR #4100 opened (branch pr-4099, same commit)
11:24:47 Review dispatched for #4100 (run 29150922585)
11:25:35 Review agent posts "Started" on #4100
11:29:22 Qodo posts 7 findings on #4099 (5 bugs, 2 rule violations — including forge.Client bypass)

Waste analysis

This close-and-recreate triggered 3 wasted or partially-wasted agent runs on the same commit:

  1. Review on closed feat(poll): implement Phase 2 cron poller for GitLab event dispatch #4099 (still in_progress): The pre-review.sh correctly checks PR state, but the check passed at ~11:23:44 when the PR was still open. The PR closed 36 seconds later. The review agent has no mid-flight state check and continues running on a closed PR. Estimated waste: 10–15 min of LLM compute.

  2. Retro on closed feat(poll): implement Phase 2 cron poller for GitLab event dispatch #4099 (this run): PR was open for 51 seconds with zero completed reviews and no meaningful agent interaction. The 60-second debounce completed, then pre-retro.sh ran — but pre-retro.sh only validates URL format, not PR lifecycle quality. Zero signal available for retro analysis.

  3. Review on feat(poll): implement Phase 2 cron poller for GitLab event dispatch #4100: This is the legitimate review on the successor PR. However, if the feat(poll): implement Phase 2 cron poller for GitLab event dispatch #4099 review completes, its findings would be identical and reusable — saving a full review cycle.

Existing issues covering these patterns

All identified improvements are already tracked by open issues. No novel proposals are warranted:

Qodo review findings (for reference)

Qodo completed a 7-finding review on the closed PR before the fullsend review agent finished. Notable finding: forge.Client architectural violation — the new poll.GitLabClient interface bypasses the required forge.Client abstraction documented in AGENTS.md. This is exactly the type of finding AGENTS.md instructs reviewers to flag as medium+ severity. The fullsend review agent's handling of this finding (once it completes on #4100) will be informative for autonomy-readiness assessment.

Conclusion

The close-and-recreate pattern is a well-understood waste driver with 6+ open issues tracking solutions. Implementing #3204 (skip retro on unmerged PRs) and #2388 (cancel in-flight review on close) would have prevented all waste observed here. No new proposals filed — the improvement backlog already captures the right fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant