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
17 changes: 17 additions & 0 deletions docs/ADRs/0067-gitlab-cron-polling-event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,23 @@ This is an accepted tradeoff — the alternative (sharing a
processed-note-IDs set or cross-reading watermarks between modes)
adds state coupling that complicates the independent-schedule design.

> **Update (2026-08, #5959):** The dual-schedule architecture above was replaced
> by a single `*/5 * * * *` schedule with automatic full-poll promotion. The
> poller now decides at runtime whether to run a fast poll or full poll based on
> elapsed time since the last full poll (`FULLSEND_LAST_POLL_AT_FULL`). This
> eliminates the tier distinction (Premium vs Free), the separate fast/full
> schedules, and the `FULLSEND_POLL_MODE` variable. The fast-poll watermark
> (`FULLSEND_LAST_POLL_AT_FAST`) is still used for slash-command-only cycles.
> Free tier in-CI polling is no longer supported by this schedule (Free tier's
> minimum interval is 60 minutes); Free tier users should use off-system polling
> (`fullsend poll` on a VM or Kubernetes CronJob) as documented in "GitLab tier
> considerations" below. Superseded sections: "Multi-frequency polling" above,
> the "5 minutes on Premium/Ultimate, 60 minutes on Free tier" reference in the
> cron-poller introduction, "Multi-frequency polling" and fast-poll MR note
> limitation under "Slash command latency", the Free tier 60-minute interval
> references in "GitLab tier considerations", and the "5 minutes on Premium, 60
> minutes on Free" latency in "Consequences".

### Event routing

The design goal is **functional event-type parity with GitHub** — users see the
Expand Down
35 changes: 15 additions & 20 deletions internal/cli/poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@ import (

func newPollCmd() *cobra.Command {
var (
forgeFlag string
inputDriver string
projectPath string
gitlabURL string
outputPath string
pollModeFlag string
fullsendDir string
jiraURL string
jiraProject string
jqlOverride string
targetRepo string
forgeFlag string
inputDriver string
projectPath string
gitlabURL string
outputPath string
fullsendDir string
jiraURL string
jiraProject string
jqlOverride string
targetRepo string
)

cmd := &cobra.Command{
Expand All @@ -55,8 +54,6 @@ func newPollCmd() *cobra.Command {
return fmt.Errorf("--project or CI_PROJECT_PATH is required")
}

slashCommandsOnly := pollModeFlag == "fast" || os.Getenv("FULLSEND_POLL_MODE") == "fast"

glClient, err := gitlab.New(forgeToken, gitlab.WithBaseURL(gitlabURL))
if err != nil {
return fmt.Errorf("create GitLab client: %w", err)
Expand All @@ -83,12 +80,11 @@ func newPollCmd() *cobra.Command {
}

opts := poll.Options{
SlashCommandsOnly: slashCommandsOnly,
BotUserID: botUserID,
GitLabURL: gitlabURL,
PipelineRef: pipelineRef,
PollJobURL: os.Getenv("CI_JOB_URL"),
DispatchSecret: os.Getenv("FULLSEND_DISPATCH_SECRET"),
BotUserID: botUserID,
GitLabURL: gitlabURL,
PipelineRef: pipelineRef,
PollJobURL: os.Getenv("CI_JOB_URL"),
DispatchSecret: os.Getenv("FULLSEND_DISPATCH_SECRET"),
}

poller := poll.New(pollClient, router, projectPath, opts)
Expand All @@ -101,7 +97,6 @@ func newPollCmd() *cobra.Command {
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 (jira-poll only; ignored by --forge gitlab)")
cmd.Flags().StringVar(&pollModeFlag, "poll-mode", "", "Poll mode: fast (slash commands only) or full")
cmd.Flags().StringVar(&fullsendDir, "fullsend-dir", "", "path to the .fullsend configuration directory")
_ = cmd.MarkFlagRequired("fullsend-dir")
cmd.Flags().StringVar(&jiraURL, "jira-url", "", "Jira instance base URL (default: $JIRA_BASE_URL)")
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/poll_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
func clearPollEnv(t *testing.T) {
t.Helper()
for _, v := range []string{
"FULLSEND_FORGE_TOKEN", "CI_PROJECT_PATH", "FULLSEND_POLL_MODE",
"FULLSEND_FORGE_TOKEN", "CI_PROJECT_PATH",
"CI_COMMIT_REF_NAME", "CI_DEFAULT_BRANCH", "CI_JOB_URL",
"JIRA_BASE_URL", "GITHUB_REPOSITORY",
"JIRA_TOKEN", "JIRA_USER_EMAIL",
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/repos.go
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ func runReposInstall(ctx context.Context, opts *reposInstallConfig) error {
continue
}

_, schedErr := setupGitLabPipelineSchedules(ctx, fc.Client, glClient, printer, r.Owner, r.Repo, targetRepo.DefaultBranch)
schedErr := setupGitLabPipelineSchedules(ctx, fc.Client, printer, r.Owner, r.Repo, targetRepo.DefaultBranch)
if schedErr != nil {
printer.StepWarn(fmt.Sprintf("[%s] Pipeline schedule setup failed: %v", repoFullName, schedErr))
}
Expand Down
51 changes: 12 additions & 39 deletions internal/cli/repos_gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,18 +217,10 @@ func storeSecretManagerToken(ctx context.Context, gcpClient gcf.GCFClient, print
return nil
}

// setupGitLabPipelineSchedules creates pipeline schedules for polling.
// Enterprise instances get dual fast/full poll schedules; free/CE instances
// get a single hourly schedule.
func setupGitLabPipelineSchedules(ctx context.Context, client forge.Client, glClient *gitlab.LiveClient, printer *ui.Printer, owner, repo, defaultBranch string) (bool, error) {
printer.StepStart("Detecting GitLab tier")
isEnterprise := glClient != nil && glClient.IsEnterprise(ctx)
if isEnterprise {
printer.StepDone("Detected tier: enterprise")
} else {
printer.StepDone("Detected tier: free")
}

// setupGitLabPipelineSchedules creates a single pipeline schedule for
// polling. The schedule runs every 5 minutes; the poller auto-promotes
// to a full poll when 15+ minutes have elapsed since the last full poll.
func setupGitLabPipelineSchedules(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, defaultBranch string) error {
// Delete existing fullsend schedules to avoid duplicates on re-install.
existing, listErr := client.ListPipelineSchedules(ctx, owner, repo)
if listErr != nil {
Expand All @@ -245,34 +237,15 @@ func setupGitLabPipelineSchedules(ctx context.Context, client forge.Client, glCl
}
}

printer.StepStart("Creating pipeline schedules")
if isEnterprise {
fastID, err := client.CreatePipelineSchedule(ctx, owner, repo, defaultBranch,
"fullsend fast poll", "*/5 * * * *",
map[string]string{"FULLSEND_POLL_MODE": "fast"})
if err != nil {
printer.StepFail("Failed to create fast poll schedule")
return isEnterprise, fmt.Errorf("creating fast poll schedule: %w", err)
}
fullID, err := client.CreatePipelineSchedule(ctx, owner, repo, defaultBranch,
"fullsend full poll", "*/15 * * * *",
map[string]string{"FULLSEND_POLL_MODE": "full"})
if err != nil {
printer.StepFail("Failed to create full poll schedule")
return isEnterprise, fmt.Errorf("creating full poll schedule: %w", err)
}
printer.StepDone(fmt.Sprintf("Created dual poll schedules (fast: ID %d, full: ID %d)", fastID, fullID))
} else {
scheduleID, err := client.CreatePipelineSchedule(ctx, owner, repo, defaultBranch,
"fullsend poll", "0 * * * *", nil)
if err != nil {
printer.StepFail("Failed to create poll schedule")
return isEnterprise, fmt.Errorf("creating poll schedule: %w", err)
}
printer.StepDone(fmt.Sprintf("Created hourly poll schedule (ID %d)", scheduleID))
printer.StepStart("Creating pipeline schedule")
scheduleID, err := client.CreatePipelineSchedule(ctx, owner, repo, defaultBranch,
"fullsend poll", "*/5 * * * *", nil)
if err != nil {
printer.StepFail("Failed to create poll schedule")
return fmt.Errorf("creating poll schedule: %w", err)
}

return isEnterprise, nil
printer.StepDone(fmt.Sprintf("Created poll schedule (ID %d)", scheduleID))
return nil
}

// cleanupGitLabPipelineSchedules removes all fullsend-prefixed pipeline
Expand Down
111 changes: 8 additions & 103 deletions internal/cli/repos_gitlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,125 +251,42 @@ func TestSetupGitLabBotToken(t *testing.T) {
func TestSetupGitLabPipelineSchedules(t *testing.T) {
ctx := context.Background()

t.Run("enterprise gets dual schedules", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"enterprise": true})
})
srv := httptest.NewServer(mux)
defer srv.Close()

glClient, err := gitlab.New("test-token", gitlab.WithBaseURL(srv.URL))
require.NoError(t, err)

t.Run("creates single poll schedule", func(t *testing.T) {
fake := &forge.FakeClient{}
var buf bytes.Buffer
printer := ui.New(&buf)

isEnterprise, err := setupGitLabPipelineSchedules(ctx, fake, glClient, printer, "group", "project", "main")
err := setupGitLabPipelineSchedules(ctx, fake, printer, "group", "project", "main")
require.NoError(t, err)
assert.True(t, isEnterprise)
require.Len(t, fake.CreatedSchedules, 2)
assert.Equal(t, "*/5 * * * *", fake.CreatedSchedules[0].Cron)
assert.Equal(t, "*/15 * * * *", fake.CreatedSchedules[1].Cron)
})

t.Run("free tier gets hourly schedule", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"enterprise": false})
})
srv := httptest.NewServer(mux)
defer srv.Close()

glClient, err := gitlab.New("test-token", gitlab.WithBaseURL(srv.URL))
require.NoError(t, err)

fake := &forge.FakeClient{}
var buf bytes.Buffer
printer := ui.New(&buf)

isEnterprise, err := setupGitLabPipelineSchedules(ctx, fake, glClient, printer, "group", "project", "main")
require.NoError(t, err)
assert.False(t, isEnterprise)
require.Len(t, fake.CreatedSchedules, 1)
assert.Equal(t, "0 * * * *", fake.CreatedSchedules[0].Cron)
assert.Equal(t, "*/5 * * * *", fake.CreatedSchedules[0].Cron)
assert.Equal(t, "fullsend poll", fake.CreatedSchedules[0].Description)
})
}

func TestSetupGitLabPipelineSchedules_FreeScheduleError(t *testing.T) {
func TestSetupGitLabPipelineSchedules_ScheduleError(t *testing.T) {
ctx := context.Background()

mux := http.NewServeMux()
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"enterprise": false})
})
srv := httptest.NewServer(mux)
defer srv.Close()

glClient, err := gitlab.New("test-token", gitlab.WithBaseURL(srv.URL))
require.NoError(t, err)

fake := forge.NewFakeClient()
fake.Errors["CreatePipelineSchedule"] = fmt.Errorf("quota exceeded")
var buf bytes.Buffer
printer := ui.New(&buf)

_, err = setupGitLabPipelineSchedules(ctx, fake, glClient, printer, "group", "project", "main")
err := setupGitLabPipelineSchedules(ctx, fake, printer, "group", "project", "main")
require.Error(t, err)
assert.Contains(t, err.Error(), "creating poll schedule")
}

func TestSetupGitLabPipelineSchedules_EnterpriseFastScheduleError(t *testing.T) {
ctx := context.Background()

mux := http.NewServeMux()
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"enterprise": true})
})
srv := httptest.NewServer(mux)
defer srv.Close()

glClient, err := gitlab.New("test-token", gitlab.WithBaseURL(srv.URL))
require.NoError(t, err)

fake := forge.NewFakeClient()
fake.Errors["CreatePipelineSchedule"] = fmt.Errorf("quota exceeded")
var buf bytes.Buffer
printer := ui.New(&buf)

isEnterprise, err := setupGitLabPipelineSchedules(ctx, fake, glClient, printer, "group", "project", "main")
require.Error(t, err)
assert.True(t, isEnterprise)
assert.Contains(t, err.Error(), "creating fast poll schedule")
}

func TestSetupGitLabPipelineSchedules_ListError(t *testing.T) {
ctx := context.Background()

mux := http.NewServeMux()
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"enterprise": false})
})
srv := httptest.NewServer(mux)
defer srv.Close()

glClient, err := gitlab.New("test-token", gitlab.WithBaseURL(srv.URL))
require.NoError(t, err)

fake := forge.NewFakeClient()
fake.Errors["ListPipelineSchedules"] = fmt.Errorf("forbidden")
var buf bytes.Buffer
printer := ui.New(&buf)

isEnterprise, err := setupGitLabPipelineSchedules(ctx, fake, glClient, printer, "group", "project", "main")
err := setupGitLabPipelineSchedules(ctx, fake, printer, "group", "project", "main")
require.NoError(t, err)
assert.False(t, isEnterprise)
assert.Contains(t, buf.String(), "Could not list existing schedules")
}

Expand Down Expand Up @@ -492,17 +409,6 @@ func TestSetupGitLabBotToken_RevokesExistingBeforeCreate(t *testing.T) {
func TestSetupGitLabPipelineSchedules_DeletesExisting(t *testing.T) {
ctx := context.Background()

mux := http.NewServeMux()
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"enterprise": false})
})
srv := httptest.NewServer(mux)
defer srv.Close()

glClient, err := gitlab.New("test-token", gitlab.WithBaseURL(srv.URL))
require.NoError(t, err)

fake := &forge.FakeClient{
PipelineSchedules: map[string][]forge.PipelineSchedule{
"group/project": {
Expand All @@ -514,9 +420,8 @@ func TestSetupGitLabPipelineSchedules_DeletesExisting(t *testing.T) {
var buf bytes.Buffer
printer := ui.New(&buf)

isEnterprise, err := setupGitLabPipelineSchedules(ctx, fake, glClient, printer, "group", "project", "main")
err := setupGitLabPipelineSchedules(ctx, fake, printer, "group", "project", "main")
require.NoError(t, err)
assert.False(t, isEnterprise)
assert.Equal(t, []int64{5}, fake.DeletedScheduleIDs, "should delete existing fullsend schedule")
require.Len(t, fake.CreatedSchedules, 1)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/poll/dispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ func TestDispatch_CreatePipelineErrorPropagates(t *testing.T) {

func TestRunCreatePipelineFailureDoesNotAdvanceWatermark(t *testing.T) {
now := time.Now().Truncate(time.Second)
since := now.Add(-10 * time.Minute)
since := now.Add(-20 * time.Minute)
mc := newMockClient()
mc.variables["FULLSEND_LAST_POLL_AT_FULL"] = since.Format(time.RFC3339)
mc.pipelineErr = fmt.Errorf("API error: 500 internal server error")
Expand Down Expand Up @@ -395,7 +395,7 @@ func TestRunCreatePipelineFailureDoesNotAdvanceWatermark(t *testing.T) {

func TestRunPartialDispatchFailure(t *testing.T) {
now := time.Now().Truncate(time.Second)
since := now.Add(-10 * time.Minute)
since := now.Add(-20 * time.Minute)
mc := newMockClient()
mc.variables["FULLSEND_LAST_POLL_AT_FULL"] = since.Format(time.RFC3339)
// Fail after 1 successful CreatePipeline call.
Expand Down
Loading
Loading