|
| 1 | +--- |
| 2 | +title: "55. GitLab cron-polling event dispatch" |
| 3 | +status: Accepted |
| 4 | +relates_to: |
| 5 | + - agent-infrastructure |
| 6 | + - gitlab-implementation |
| 7 | + - security-threat-model |
| 8 | +topics: |
| 9 | + - gitlab |
| 10 | + - forge |
| 11 | + - ci-cd |
| 12 | + - per-repo |
| 13 | + - polling |
| 14 | + - cron |
| 15 | +--- |
| 16 | + |
| 17 | +# 55. GitLab cron-polling event dispatch |
| 18 | + |
| 19 | +Date: 2026-06-13 |
| 20 | + |
| 21 | +## Status |
| 22 | + |
| 23 | +Accepted |
| 24 | + |
| 25 | +<!-- ADRs are point-in-time records, but not fully frozen after acceptance. |
| 26 | + Minor annotations are welcome: cross-references to related ADRs, short |
| 27 | + notes linking to newer decisions, or clarifying remarks. However, do not |
| 28 | + substantially rewrite the Context, Decision, or Consequences sections. If |
| 29 | + the decision itself needs to change, write a new ADR that supersedes this |
| 30 | + one. For evolving design narrative, use docs/architecture.md. --> |
| 31 | + |
| 32 | +## Context |
| 33 | + |
| 34 | +Fullsend needs to detect and react to GitLab events — new issues, merge |
| 35 | +requests, comments, and label changes — so that agent stages (triage, code, |
| 36 | +review, fix, retro) can be dispatched automatically. On GitHub, native event |
| 37 | +triggers (`pull_request_target`, `issues`, `issue_comment`) handle this within |
| 38 | +GitHub Actions. GitLab has no equivalent for most event types. |
| 39 | + |
| 40 | +GitLab's CI/CD pipeline trigger sources are: `push`, `merge_request_event`, |
| 41 | +`schedule`, `trigger`, `web`, `api`, and `parent_pipeline`. Of these, only |
| 42 | +`merge_request_event` maps to an agent-relevant event. Issue creation, comment |
| 43 | +posting, and label changes have no native CI pipeline trigger. |
| 44 | + |
| 45 | +GitLab supports per-repo installation mode only (no per-org). The pipeline runs |
| 46 | +inside the enrolled project on the protected default branch. A bot project |
| 47 | +access token — retrieved at runtime via GitLab OIDC/GCP WIF from Secret |
| 48 | +Manager — serves as the single credential for all agent operations. |
| 49 | + |
| 50 | +See [ADR 0028](0028-gitlab-support.md) (deprecated) for the original GitLab |
| 51 | +support architecture discussion. [ADR 0045](0045-forge-portable-harness-schema.md) |
| 52 | +defines the forge-portable harness schema that GitLab stage templates must |
| 53 | +conform to. |
| 54 | + |
| 55 | +## Options |
| 56 | + |
| 57 | +### Option 1: Webhook bridge Cloud Function |
| 58 | + |
| 59 | +Deploy a GCP Cloud Function that receives GitLab webhook POST requests, |
| 60 | +validates the `X-Gitlab-Token` header, and calls the Pipeline Trigger API to |
| 61 | +dispatch agent stages. |
| 62 | + |
| 63 | +**Rejected.** Requires external infrastructure (Cloud Function) that must be |
| 64 | +deployed, monitored, and secured. Exposes a public HTTPS endpoint — an inbound |
| 65 | +attack surface. Requires three credential types per project (bot PAT, webhook |
| 66 | +secret, trigger token). Creates a complex deployment story for self-hosted |
| 67 | +GitLab behind corporate firewalls (VPN peering, on-premise containers, or |
| 68 | +Cloud Run + VPC Connector). The bridge cannot be eliminated even in a hybrid |
| 69 | +model — if any event type uses webhooks, the full bridge must be deployed. |
| 70 | + |
| 71 | +### Option 2: Webhook-only (all events via bridge) |
| 72 | + |
| 73 | +Use the webhook bridge for all events, eliminating native CI triggers. |
| 74 | + |
| 75 | +**Rejected.** Still requires the bridge with all its operational complexity. |
| 76 | +The correct response to "if we need webhooks for some events, why not all?" is |
| 77 | +to eliminate the bridge entirely, not to double down on it. |
| 78 | + |
| 79 | +### Option 3: Native MR events + webhook bridge for issues/comments |
| 80 | + |
| 81 | +Use GitLab's native `merge_request_event` for MR events, keep the webhook |
| 82 | +bridge only for issues and comments. |
| 83 | + |
| 84 | +**Rejected.** Still requires the bridge Cloud Function. The bridge's |
| 85 | +operational cost is dominated by deployment, monitoring, and credential |
| 86 | +management — not by event type count. |
| 87 | + |
| 88 | +### Option 4: Pure cron polling (no native CI triggers) |
| 89 | + |
| 90 | +Poll for all events including MR creation and updates. |
| 91 | + |
| 92 | +**Rejected.** MR events have a viable native CI path (`merge_request_event` + |
| 93 | +`include: local:`) with sub-minute latency and zero additional infrastructure. |
| 94 | +Polling for MRs adds unnecessary latency to the most frequent, most |
| 95 | +latency-sensitive operation (code review). |
| 96 | + |
| 97 | +## Decision |
| 98 | + |
| 99 | +GitLab event dispatch uses a **two-path model**: |
| 100 | + |
| 101 | +1. **Native CI triggers for MR events.** MR creation, update, reopen, and |
| 102 | + merge trigger pipelines via GitLab's `merge_request_event` pipeline source. |
| 103 | + The dispatch template is loaded via `include: local:` from the protected |
| 104 | + default branch, ensuring untrusted MR branches cannot modify dispatch logic. |
| 105 | + |
| 106 | +2. **Cron-polled events for everything else.** A scheduled pipeline runs every |
| 107 | + N minutes (5 minutes on Premium/Ultimate, 60 minutes on Free tier), queries |
| 108 | + the GitLab API for new issues, comments, and label changes since the last |
| 109 | + poll, and dispatches agent stages via parent-child pipelines. |
| 110 | + |
| 111 | +No external infrastructure is required for event dispatch — no webhook bridge, |
| 112 | +no webhook secrets, no trigger tokens. |
| 113 | + |
| 114 | +``` |
| 115 | +ENROLLED PROJECT GCP |
| 116 | +──────────────── ─── |
| 117 | +.gitlab-ci.yml (root pipeline) WIF pool/provider (validates GitLab OIDC) |
| 118 | +.gitlab/ci/dispatch.yml (MR routing) Service Account (impersonated by jobs) |
| 119 | +.gitlab/ci/poll.yml (cron poller) Secret Manager: |
| 120 | +.gitlab/ci/triage.yml … retro.yml - bot PAT per enrolled project |
| 121 | +.fullsend/ (config workspace) |
| 122 | +
|
| 123 | +MR events (native CI): |
| 124 | + MR opened/updated → merge_request_event → dispatch.yml → review/fix stage |
| 125 | +
|
| 126 | +Issues, comments, labels (cron): |
| 127 | + Pipeline schedule (5 min) → poll.yml → GitLab API → dispatch agent stage |
| 128 | +
|
| 129 | +Credentials: |
| 130 | + Pipeline job → OIDC token → GCP STS → WIF → SA → Secret Manager → bot PAT |
| 131 | +``` |
| 132 | + |
| 133 | +### Credential model |
| 134 | + |
| 135 | +A Developer-role project access token with `api` scope, created during |
| 136 | +`fullsend admin install` and stored in GCP Secret Manager. Retrieved at |
| 137 | +runtime via GitLab OIDC → GCP WIF. No credentials are stored as CI/CD |
| 138 | +variables in the enrolled project. |
| 139 | + |
| 140 | +Key properties: |
| 141 | + |
| 142 | +- **Secretless project.** The bot PAT lives in Secret Manager, not in GitLab |
| 143 | + CI/CD variables. `CI_DEBUG_TRACE` cannot expose it directly. |
| 144 | +- **Cryptographic access control.** WIF attribute conditions restrict token |
| 145 | + retrieval to the enrolled project on protected branches |
| 146 | + (`assertion.project_id` + `assertion.ref_protected == "true"`). |
| 147 | +- **Single credential type.** One bot PAT per project handles all REST and |
| 148 | + GraphQL operations. No webhook secrets, trigger tokens, or mint service. |
| 149 | +- **Bot identity.** The project access token creates a dedicated bot user, |
| 150 | + providing attributable identity equivalent to GitHub Apps. |
| 151 | +- **GraphQL support.** Unlike `CI_JOB_TOKEN`, the bot PAT authenticates |
| 152 | + GraphQL — required for GitLab's Work Items API. |
| 153 | +- **No token mint.** Standard GCP WIF replaces the custom mint Cloud Function |
| 154 | + used for GitHub. |
| 155 | + |
| 156 | +### Cron poller |
| 157 | + |
| 158 | +The poller runs as `fullsend poll` inside the fullsend container image, |
| 159 | +invoked by a scheduled pipeline on the protected default branch. It: |
| 160 | + |
| 161 | +1. Reads a timestamp watermark (`FULLSEND_LAST_POLL_AT`, protected CI/CD |
| 162 | + variable) marking the last successful poll. |
| 163 | +2. Queries the GitLab API for issues, MRs, and notes updated since the |
| 164 | + watermark (with a 30-second overlap for clock skew). |
| 165 | +3. Routes detected events to agent stages based on labels, slash commands, and |
| 166 | + state changes. |
| 167 | +4. Dispatches stages via dynamically-generated child pipeline YAML. |
| 168 | +5. Advances the watermark. |
| 169 | + |
| 170 | +Label change detection uses client-side state diffing — the poller tracks |
| 171 | +previously-seen labels per issue and triggers only on newly-added labels. This |
| 172 | +compensates for the lack of a `changes` object that webhook payloads provide. |
| 173 | + |
| 174 | +**Multi-frequency polling (Premium/Ultimate):** Two pipeline schedules — a |
| 175 | +fast poll (every 5 minutes, slash commands only) and a slow poll (every 15 |
| 176 | +minutes, full event scan). On Free tier, a single hourly poll is the only |
| 177 | +option. |
| 178 | + |
| 179 | +### Event routing |
| 180 | + |
| 181 | +| Detected Change | Signal | Stage | |
| 182 | +|---|---|---| |
| 183 | +| Issue label `fullsend:ready-to-code` added | Label state diff | code | |
| 184 | +| Issue label `fullsend:ready-for-review` added | Label state diff | review | |
| 185 | +| Issue note starting with `/fs-{triage,code,review,fix,retro,prioritize}` | Note body prefix | corresponding stage | |
| 186 | +| Issue note (non-command) on issue with `needs-info` label | Label check | triage | |
| 187 | +| MR opened/updated/reopened | Native CI (`merge_request_event`) | review | |
| 188 | +| MR merged | Native CI (`merge_request_event`) | retro | |
| 189 | +| MR note with `<!-- fullsend:changes-requested -->` | Note body marker | fix (same-project MRs only) | |
| 190 | + |
| 191 | +Bot-authored comments are skipped to prevent re-triggering loops (exception: |
| 192 | +the `changes-requested` marker from the review agent). |
| 193 | + |
| 194 | +### Slash command latency |
| 195 | + |
| 196 | +Slash commands (`/fs-*`) are the only latency-sensitive operation. Mitigations: |
| 197 | + |
| 198 | +- **Labels as primary triggers.** Applying `fullsend:review` or |
| 199 | + `fullsend:code` labels is discoverable, visible, and has no polling latency |
| 200 | + on the native MR CI path. |
| 201 | +- **Multi-frequency polling** keeps slash command latency to 5 minutes on |
| 202 | + Premium/Ultimate. |
| 203 | +- **Manual pipeline trigger** via the GitLab UI as a power-user escape hatch. |
| 204 | + |
| 205 | +**Quick Action risk:** GitLab may silently strip unrecognized `/`-prefixed |
| 206 | +lines. If confirmed empirically, GitLab should use an alternative prefix |
| 207 | +(`fs:triage` or `@fullsend triage`). [ADR 0042](0042-fs-prefix-for-slash-commands.md) |
| 208 | +permits forge-specific syntax. |
| 209 | + |
| 210 | +### GitLab tier considerations |
| 211 | + |
| 212 | +| Feature | Free | Premium | Ultimate | |
| 213 | +|---|---|---|---| |
| 214 | +| Schedule minimum interval | 60 min | 5 min | 5 min | |
| 215 | +| Project access tokens (SaaS) | Not available | Available | Available | |
| 216 | +| CODEOWNERS enforcement | Not available | Available | Available | |
| 217 | +| CI minutes (shared runners) | 400/month | 10,000/month | 50,000/month | |
| 218 | + |
| 219 | +**Free tier** is functional but degraded: 60-minute poll interval, no project |
| 220 | +access tokens on gitlab.com (must use personal access token), no CODEOWNERS |
| 221 | +guardrails, and CI minute quota is insufficient for polling on shared runners. |
| 222 | +Self-hosted runners are required. |
| 223 | + |
| 224 | +**Premium** (recommended minimum): 5-minute polling, project access tokens, |
| 225 | +CODEOWNERS enforcement, adequate CI minutes for a single project. |
| 226 | + |
| 227 | +`fullsend admin install` adapts poll frequency and interaction model to the |
| 228 | +detected tier. |
| 229 | + |
| 230 | +### Security model |
| 231 | + |
| 232 | +The security model follows the project's threat priority order (external |
| 233 | +injection > insider > drift > supply chain): |
| 234 | + |
| 235 | +- **No inbound attack surface.** Polling is entirely outbound — no public |
| 236 | + endpoint, no webhook parser, no shared-secret authentication. |
| 237 | +- **Protected branch enforcement.** `workflow:rules` require |
| 238 | + `$CI_COMMIT_REF_PROTECTED == "true"` for scheduled pipelines. WIF attribute |
| 239 | + conditions provide cryptographic defense-in-depth. |
| 240 | +- **Protected CI/CD variables.** WIF configuration variables are marked |
| 241 | + protected — accessible only to pipelines on protected branches. |
| 242 | +- **`CI_DEBUG_TRACE` guard.** Install-time validation and runtime abort if |
| 243 | + debug tracing is detected. |
| 244 | +- **Event data sanitization.** Attacker-controlled content is base64-encoded |
| 245 | + before passing to child pipelines. |
| 246 | +- **Fork MR protection.** Fix/code stages are skipped when |
| 247 | + `source_project_id != target_project_id`. |
| 248 | + |
| 249 | +### Forge abstraction |
| 250 | + |
| 251 | +[ADR 0005](0005-forge-abstraction-layer.md) requires new forges to implement |
| 252 | +`forge.Client`. This ADR adds forge-neutral methods: |
| 253 | + |
| 254 | +- `IsProtectedBranch` — maps to GitHub branch protection API and GitLab |
| 255 | + protected branches API |
| 256 | +- `CreatePipelineSchedule` / `DeletePipelineSchedule` — GitLab-native; GitHub |
| 257 | + returns `ErrNotSupported` |
| 258 | +- `UpdateVariable` — for poll watermark management |
| 259 | + |
| 260 | +A new `ErrNotSupported` sentinel (complementing the existing `ErrNotFound`, |
| 261 | +`ErrAlreadyExists`, and `ErrBranchProtected` sentinels) allows forge |
| 262 | +implementations to reject inapplicable operations. GitHub-only methods |
| 263 | +(`ListOrgInstallations`, `GetAppClientID`) move to a `GitHubExtensions` |
| 264 | +extension interface. |
| 265 | + |
| 266 | +## Consequences |
| 267 | + |
| 268 | +**What becomes easier:** |
| 269 | + |
| 270 | +- **No external infrastructure for event dispatch.** No Cloud Function, no |
| 271 | + webhook bridge. Self-hosted GitLab requires only outbound HTTPS. |
| 272 | +- **Single credential per project.** One bot PAT in Secret Manager, retrieved |
| 273 | + via OIDC/WIF. No webhook secrets, trigger tokens, or mint service changes. |
| 274 | +- **Stronger event authenticity.** Events read directly from the GitLab API, |
| 275 | + not from potentially spoofed webhook payloads. |
| 276 | +- **No event loss.** Polling reads from the source of truth. Webhooks can fail |
| 277 | + silently or auto-disable after 4 consecutive failures. |
| 278 | +- **Simpler emergency shutdown.** Disable the pipeline schedule or revoke the |
| 279 | + bot PAT. No bridge to tear down. |
| 280 | +- **MR review latency is unaffected.** Native `merge_request_event` provides |
| 281 | + sub-second triggering for the highest-frequency operation. |
| 282 | +- **Tier-adaptive.** Works on all GitLab tiers with graceful degradation. |
| 283 | +- **Reuses GCP infrastructure.** WIF and Secret Manager are already provisioned |
| 284 | + for Vertex AI inference. |
| 285 | + |
| 286 | +**What becomes harder or changes:** |
| 287 | + |
| 288 | +- **Issue/comment event latency.** Up to 5 minutes on Premium, 60 minutes on |
| 289 | + Free. Acceptable for asynchronous agent operations, poor for interactive use |
| 290 | + on Free tier. |
| 291 | +- **CI minute consumption.** Polling runs continuously. At 5-minute intervals: |
| 292 | + ~8,640 min/month on shared runners. Self-hosted runners are not billed. |
| 293 | +- **State management.** The poller must track watermarks, deduplicate events |
| 294 | + across overlapping windows, and diff label state. |
| 295 | +- **Slash command latency.** Up to 5 minutes vs sub-second with webhooks. |
| 296 | + Labels mitigate this for common operations. |
| 297 | +- **Quick Action stripping.** GitLab may strip `/fs-*` commands from comments. |
| 298 | + Requires testing and potentially alternative syntax. |
| 299 | +- **Per-repo only.** No centralized config or credential management across |
| 300 | + projects. |
| 301 | +- **`api` scope is broad.** Narrower scopes are not available in GitLab today. |
| 302 | + |
| 303 | +**Risks** (ordered by threat priority): |
| 304 | + |
| 305 | +1. **Prompt injection via polled events.** Attacker-controlled issue/MR content |
| 306 | + reaches the agent. Mitigated by base64 encoding of event payloads passed to |
| 307 | + child pipelines. The transport mechanism does not change the content risk. |
| 308 | +2. **Watermark tampering.** A Maintainer could skip or replay events by |
| 309 | + modifying `FULLSEND_LAST_POLL_AT`. Mitigated by protected variable status |
| 310 | + and event deduplication. |
| 311 | +3. **Schedule modification.** A Maintainer could retarget the schedule to a |
| 312 | + non-protected branch. Mitigated by WIF attribute conditions rejecting |
| 313 | + credential retrieval. |
| 314 | +4. **Missed events from API quirks.** The Notes API lacks `created_after`; the |
| 315 | + Events API `after` parameter is date-only. Mitigated by 30-second watermark |
| 316 | + overlap and dual-frequency polling as reconciliation. |
| 317 | + |
| 318 | +**Comparison with GitHub:** |
| 319 | + |
| 320 | +| Concern | GitHub | GitLab (this ADR) | |
| 321 | +|---|---|---| |
| 322 | +| Primary credential | App installation token via mint | Bot PAT via OIDC/WIF | |
| 323 | +| MR/PR event dispatch | `pull_request_target` | `merge_request_event` | |
| 324 | +| Issue/comment dispatch | Native events (sub-second) | Cron polling (5 min) | |
| 325 | +| External infrastructure | Mint Cloud Function | None for event dispatch | |
| 326 | +| Credential types | App key + installation token | Single bot PAT | |
| 327 | + |
| 328 | +## Implementation |
| 329 | + |
| 330 | +Detailed implementation guidance is in the companion document: |
| 331 | +[docs/plans/gitlab-cron-polling-implementation.md](../plans/gitlab-cron-polling-implementation.md). |
| 332 | + |
| 333 | +Six phases: forge interface preparation → GitLab forge client → cron poller → |
| 334 | +CI/CD templates → CLI changes → integration testing. |
0 commit comments