Skip to content

Commit d5227c8

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 138d6a3 commit d5227c8

5 files changed

Lines changed: 1716 additions & 3 deletions

File tree

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

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)