Skip to content

Commit ad4ebe1

Browse files
committed
docs: ADR 0055 — GitLab cron-polling event dispatch
Add ADR 0055 documenting the two-path event dispatch model for GitLab: native CI triggers for MR events (merge_request_event) and cron-based polling for issues, comments, and label changes. No external infrastructure required — no webhook bridge, no Cloud Function. Includes companion implementation plan with six phases: forge interface preparation, GitLab forge client, cron poller, CI/CD templates, CLI changes, and integration testing. Signed-off-by: Claude <noreply@anthropic.com> Signed-off-by: Greg Allen <gallen@redhat.com>
1 parent 7095039 commit ad4ebe1

4 files changed

Lines changed: 1667 additions & 1 deletion

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
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.

docs/architecture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Infrastructure platform choice and configuration are specified in the adopting o
4545
- Installer scaffold: the `WorkflowsLayer` deploys content from an embedded scaffold (`internal/scaffold/`), keeping deployable files as real files under version control rather than Go string constants.
4646
- Reusable workflows: agent workflows in `.fullsend` are thin callers (~40-70 lines) that delegate infrastructure logic to upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) via `workflow_call`. Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md)). **`--vendor`** ([ADR 0047](ADRs/0047-vendored-installs-with-vendor-flag.md)) commits workflows and agent content at install time; layered installs (default) fetch upstream at runtime.
4747
- Event-driven stage dispatch: eliminate `workflow_dispatch` + `gh workflow run` fan-out from `dispatch.yml` in favor of synchronous `workflow_call` so the dispatched run stays linked to the caller ([ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).
48+
- GitLab event dispatch: two-path model — native CI triggers (`merge_request_event`) for MR events, cron-based polling for issues/comments/labels. No external infrastructure (no webhook bridge). Bot PAT via OIDC/WIF from Secret Manager. Per-repo only ([ADR 0055](ADRs/0055-gitlab-cron-polling-event-dispatch.md)).
4849

4950
**Open questions:**
5051

@@ -155,6 +156,7 @@ The existing design principle is that [the repo is the coordinator](problems/age
155156
**Decided:**
156157

157158
- Event-driven stage dispatch runs synchronously via `workflow_call` to preserve run correlation in the GitHub Actions UI (see [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).
159+
- GitLab dispatch uses cron-polled scheduled pipelines for issue/comment/label events and native `merge_request_event` for MR events. No webhook bridge required (see [ADR 0055](ADRs/0055-gitlab-cron-polling-event-dispatch.md)).
158160

159161
**Open questions:**
160162

0 commit comments

Comments
 (0)