Module 3: GitLab CI/CD and Headless SDK
GitLab Pipeline with the Headless SDK
GitLab Pipeline with the Headless SDK
Overview
This capsule combines what you learned in the two previous ones: the headless SDK (capsule 02) and the GitLab pipeline model (capsule 03). The result is a real GitLab CI/CD pipeline that runs Claude Code on every Merge Request, with secrets handled, artifacts between stages, and output published to the MR.
You'll build the pipeline step by step: Docker executor setup, CI/CD variable configuration, an SDK script that generates the review, and publishing comments to the MR via the GitLab API. By the end, you'll have an operational pipeline equivalent to the GitHub Actions one from Module 1, but running on GitLab.
The Minimal Setup
Repo structure:
my-project/
├── .gitlab-ci.yml
├── scripts/
│ ├── extract_diff.py
│ ├── code_review.py
│ └── publish_to_mr.py
├── CLAUDE.md
├── requirements.txt
└── ... (project code)
requirements.txt
anthropic>=0.39.0,<1.0.0
python-gitlab>=4.0.0
Configure CI/CD Variables
In GitLab: Settings → CI/CD → Variables, add:
| Variable | Value | Flags |
|---|---|---|
ANTHROPIC_API_KEY | your API key | Protected ✅, Masked ✅ |
GITLAB_API_TOKEN | personal access token with the api scope | Protected ✅, Masked ✅ |
Note on GITLAB_API_TOKEN: GitLab provides $CI_JOB_TOKEN automatically, but it has a limited scope and doesn't allow posting comments in some configurations. That's why you configure an explicit PAT (Personal Access Token) with the api scope.
The Complete Pipeline
# .gitlab-ci.yml
stages:
- prepare
- analyze
- publish
variables:
PYTHON_VERSION: "3.11"
CLAUDE_MODEL: "claude-haiku-4-5"
CLAUDE_MAX_TOKENS: "4000"
default:
image: python:3.11-slim
before_script:
- apt-get update && apt-get install -y --no-install-recommends git
- pip install --cache-dir=.pip-cache --no-warn-script-location -r requirements.txt
cache:
key: pip-cache-${CI_COMMIT_REF_SLUG}
paths:
- .pip-cache/
# ============================================
# STAGE 1: Extract the MR's diff
# ============================================
extract-diff:
stage: prepare
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- "src/**/*"
- "lib/**/*"
- "tests/**/*"
- "package.json"
- "requirements.txt"
script:
- python scripts/extract_diff.py
artifacts:
paths:
- filtered_diff.txt
- pr_files.json
expire_in: 1 day
# ============================================
# STAGE 2: Analysis with the Claude Code SDK
# ============================================
claude-review:
stage: analyze
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
needs:
- extract-diff
script:
- python scripts/code_review.py
artifacts:
paths:
- review_result.json
expire_in: 7 days
# ============================================
# STAGE 3: Publish to the MR
# ============================================
publish-review:
stage: publish
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
needs:
- claude-review
script:
- python scripts/publish_to_mr.py
Characteristics:
- 3 sequential stages
- Each job explicitly depends on the previous one with
needs: - Artifacts pass files between stages
- Pip cache per branch
- Only runs on MRs with code changes (path filter)
Script 1: Extract the Diff
"""scripts/extract_diff.py — extracts the MR's diff."""
import json
import os
import subprocess
import sys
from pathlib import Path
import gitlab
def get_pr_files_via_gitlab_api():
"""Get the list of the MR's files via the GitLab API."""
gl = gitlab.Gitlab(
os.environ["CI_SERVER_URL"],
private_token=os.environ["GITLAB_API_TOKEN"],
)
project = gl.projects.get(os.environ["CI_PROJECT_ID"])
mr = project.mergerequests.get(int(os.environ["CI_MERGE_REQUEST_IID"]))
changes = mr.changes()
return changes.get("changes", [])
def filter_relevant(files):
"""Filter files relevant for review."""
RELEVANT_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb"}
def is_relevant(f):
path = f.get("new_path", f.get("old_path", ""))
if not any(path.endswith(ext) for ext in RELEVANT_EXTENSIONS):
return False
if "test" in path or "fixture" in path:
return False
if f.get("deleted_file"):
return False
return True
return [f for f in files if is_relevant(f)]
def main() -> int:
try:
files = get_pr_files_via_gitlab_api()
relevant = filter_relevant(files)
if not relevant:
print("No relevant files to review.")
Path("filtered_diff.txt").write_text("")
Path("pr_files.json").write_text("[]")
return 0
# Build the combined diff
diff_parts = []
for f in relevant:
path = f.get("new_path", "")
patch = f.get("diff", "")
if patch:
diff_parts.append(f"--- {path} ---\n{patch}\n")
combined_diff = "\n".join(diff_parts)
Path("filtered_diff.txt").write_text(combined_diff)
Path("pr_files.json").write_text(json.dumps(relevant, indent=2))
print(f"Total files: {len(files)}")
print(f"Relevant: {len(relevant)}")
print(f"Diff size: {len(combined_diff.splitlines())} lines")
return 0
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
Differences from the GitHub version:
- Uses
python-gitlabinstead ofrequestsdirectly - Variables:
CI_SERVER_URL,CI_PROJECT_ID,CI_MERGE_REQUEST_IID(notGITHUB_*) - The
mr.changes()method returns GitLab's structure (new_path,diff,deleted_file)
Script 2: Code Review with the SDK
"""scripts/code_review.py — review with the Claude Code SDK."""
import json
import os
import re
import sys
from pathlib import Path
from anthropic import Anthropic, APIError
def load_conventions() -> str:
"""Load CLAUDE.md if it exists."""
p = Path("CLAUDE.md")
return p.read_text() if p.exists() else "No documented conventions."
def build_prompt(diff: str, conventions: str) -> str:
"""Build the complete review prompt."""
return f"""You are a professional code reviewer for this project.
PROJECT CONVENTIONS:
{conventions}
INSTRUCTIONS:
- Apply the project's conventions, not generic conventions
- Only comment on actionable issues (not "looks good")
- Severity "critical" only for real bugs or vulnerabilities
OUTPUT: return ONLY a JSON with this structure:
{{
"summary": {{
"overview": "1-2 paragraphs describing what changes and the overall quality",
"highlights": ["point 1", "point 2", "..."],
"verdict": "ready_to_merge|needs_minor_changes|needs_major_changes"
}},
"comments": [
{{
"path": "path/to/file",
"line": 42,
"severity": "critical|warning|suggestion",
"body": "Comment text in markdown"
}}
]
}}
DIFF TO REVIEW:
{diff}
"""
def parse_review_json(text: str) -> dict:
"""Parse JSON handling code fences."""
text = text.strip()
text = re.sub(r"^```(?:json)?\n?", "", text)
text = re.sub(r"\n?```$", "", text)
return json.loads(text)
def main() -> int:
diff_path = Path("filtered_diff.txt")
if not diff_path.exists() or not diff_path.read_text().strip():
print("No diff to analyze. Skipping.")
Path("review_result.json").write_text(
json.dumps({"summary": None, "comments": [], "skipped": True})
)
return 0
try:
diff = diff_path.read_text()
conventions = load_conventions()
prompt = build_prompt(diff, conventions)
client = Anthropic()
response = client.messages.create(
model=os.environ.get("CLAUDE_MODEL", "claude-haiku-4-5"),
max_tokens=int(os.environ.get("CLAUDE_MAX_TOKENS", "4000")),
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text
parsed = parse_review_json(text)
result = {
"summary": parsed["summary"],
"comments": parsed["comments"],
"tokens_used": {
"input": response.usage.input_tokens,
"output": response.usage.output_tokens,
},
"skipped": False,
}
Path("review_result.json").write_text(json.dumps(result, indent=2))
print(f"Review generated: {len(result['comments'])} comments")
print(f"Tokens: {result['tokens_used']['input']} in / {result['tokens_used']['output']} out")
return 0
except APIError as e:
print(f"Anthropic API ERROR: {e}", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"ERROR parsing JSON: {e}\nText:\n{text}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
Notes:
review_result.jsonis left as an artifact for the next stage- The empty-diff case is handled (a silent but orderly skip)
- API and JSON parsing errors have specific messages for debugging
Script 3: Publish to the MR
"""scripts/publish_to_mr.py — publish the review as comments on a GitLab MR."""
import json
import os
import sys
from pathlib import Path
import gitlab
MARKER = "<!-- claude-code-bot -->"
def build_summary_markdown(review: dict) -> str:
"""Build the summary markdown."""
summary = review["summary"]
comments = review["comments"]
by_severity = {"critical": 0, "warning": 0, "suggestion": 0}
for c in comments:
by_severity[c.get("severity", "suggestion")] += 1
verdict_emoji = {
"ready_to_merge": "✅",
"needs_minor_changes": "⚠️",
"needs_major_changes": "🚨",
}.get(summary.get("verdict", ""), "💬")
body = f"""{MARKER}
# 🤖 Code Review (Claude Code)
## Summary
{summary.get("overview", "")}
### Key points
"""
for h in summary.get("highlights", []):
body += f"- {h}\n"
body += f"""
### Verdict
{verdict_emoji} **{summary.get("verdict", "").replace("_", " ").title()}**
### Severity
- 🚨 Critical: {by_severity['critical']}
- ⚠️ Warning: {by_severity['warning']}
- 💡 Suggestion: {by_severity['suggestion']}
Details inline in the affected files.
"""
return body
def find_existing_bot_comment(notes):
"""Look for an existing bot comment."""
for note in notes:
if MARKER in note.body:
return note
return None
def publish_summary(mr, body: str):
"""Create or update the general summary."""
notes = mr.notes.list(get_all=True)
existing = find_existing_bot_comment(notes)
if existing:
existing.body = body
existing.save()
print(f"Summary updated on note {existing.id}")
else:
new_note = mr.notes.create({"body": body})
print(f"Summary created: note {new_note.id}")
def publish_inline_comments(mr, comments: list):
"""Publish inline comments on specific lines."""
# GitLab uses "discussions" with a position for inline comments
for c in comments:
try:
mr.discussions.create({
"body": format_severity(c["severity"]) + c["body"],
"position": {
"base_sha": mr.diff_refs["base_sha"],
"start_sha": mr.diff_refs["start_sha"],
"head_sha": mr.diff_refs["head_sha"],
"position_type": "text",
"new_path": c["path"],
"new_line": c["line"],
},
})
print(f" ✓ Inline comment: {c['path']}:{c['line']}")
except Exception as e:
print(f" ✗ Skip inline ({c['path']}:{c['line']}): {e}")
def format_severity(severity: str) -> str:
return {
"critical": "🚨 **CRITICAL** — ",
"warning": "⚠️ **WARNING** — ",
"suggestion": "💡 **Suggestion** — ",
}.get(severity, "")
def main() -> int:
review_file = Path("review_result.json")
if not review_file.exists():
print("ERROR: review_result.json not found.", file=sys.stderr)
return 1
review = json.loads(review_file.read_text())
if review.get("skipped"):
print("Review was skipped (no diff). Not publishing.")
return 0
try:
gl = gitlab.Gitlab(
os.environ["CI_SERVER_URL"],
private_token=os.environ["GITLAB_API_TOKEN"],
)
project = gl.projects.get(os.environ["CI_PROJECT_ID"])
mr = project.mergerequests.get(int(os.environ["CI_MERGE_REQUEST_IID"]))
# General summary
summary_md = build_summary_markdown(review)
publish_summary(mr, summary_md)
# Inline comments
if review.get("comments"):
print(f"Publishing {len(review['comments'])} inline comments...")
publish_inline_comments(mr, review["comments"])
return 0
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
Notes:
python-gitlabsimplifies the API calls (instead of rawrequests)- Inline comments in GitLab are called "discussions" with a
position - It needs the MR's
diff_refsto anchor inline comments to the right diff - It handles inline comment errors individually (it doesn't fail the whole job)
Running the Pipeline
- Push
.gitlab-ci.yml+scripts/to a branch - Configure the
ANTHROPIC_API_KEYandGITLAB_API_TOKENvariables in Settings - Create an MR against
main - Go to CI/CD → Pipelines → see the run
Expected result:
- Stage
prepare→ extracts the diff and files into artifacts (~10-30s) - Stage
analyze→ runs Claude Code, generatesreview_result.json(~10-60s) - Stage
publish→ posts the summary + inline comments to the MR (~5-10s)
In the MR you'll see:
- A general bot comment at the end
- Inline comments on specific files
Common Pitfalls in GitLab Pipelines
Error 1: Using $CI_JOB_TOKEN for API calls
Symptom: The API responds 401 when trying to post comments.
Why it happens: CI_JOB_TOKEN has a limited scope in GitLab self-hosted or specific configurations. It doesn't always allow writing.
How to fix it: Create a Personal Access Token (PAT) or Project Access Token with the api scope and configure it as GITLAB_API_TOKEN.
Error 2: Forgetting apt-get install git in the Docker image
Symptom: git diff fails with "command not found".
Why it happens: Minimal images like python:3.11-slim don't include git by default.
How to fix it: Install git in before_script:
before_script:
- apt-get update && apt-get install -y --no-install-recommends git
Error 3: needs: without being in the same pipeline
Symptom: The job that needs artifacts fails because "depends on extract-diff which is not in this pipeline".
Why it happens: The rules: excluded extract-diff but claude-review still depends on it.
How to fix it: Same rules on jobs that need each other, or use needs:[]:optional.
Error 4: Inline comments with lines outside the diff
Symptom: The job fails with "the position is invalid" when posting inline comments.
Why it happens: The model suggested commenting on a line that isn't in the MR's diff.
How to fix it: Validate the line against the patch before publishing (similar to the pattern in capsule 03 of module 2). Skip invalid inline comments instead of failing the whole job.
Error 5: Pip cache per job without sharing
Symptom: Each job installs dependencies from scratch, the pipeline takes a long time.
Why it happens: No cache configured or a different key: in each job.
How to fix it: A shared cache with key: pip-cache-${CI_COMMIT_REF_SLUG} and paths: [.pip-cache/] in the default:.
Diagnosis
Question 1: Does your pipeline have 3 clear stages (prepare, analyze, publish) or everything in a single job?
3 stages = better structure, better observability. Everything in one job = simpler but less visible when something fails.
Question 2: Did you configure `GITLAB_API_TOKEN` or are you trying with `CI_JOB_TOKEN`?
CI_JOB_TOKEN may not work for posting comments. A PAT with the api scope is more reliable.
Question 3: Does your Docker image have `git` installed?
If you use python:3.11-slim, it doesn't come with it. You have to install it in before_script.
Question 4: Do you validate inline comment lines before publishing?
If not, you'll have intermittent errors in jobs when the model suggests lines outside the diff.
Question 5: Are the `rules:` consistent between jobs that need each other via `needs:`?
If a job has more restrictive rules than another that depends on it, the dependency fails.
Exercises
Exercise 1: Minimal working pipeline (Medium)
Configure the 3 files (.gitlab-ci.yml + 2 scripts) in a test repo with an MR. Verify that:
- The pipeline runs on the MR
- The 3 stages run in order
- A bot comment shows up on the MR
Exercise 2: Add a path filter (Easy)
Modify the rules so the pipeline only runs when the MR changes *.py or *.ts files. Verify with an MR that only changes docs (shouldn't trigger) and another that changes code (should trigger).
Exercise 3: Inline comments with validation (Hard)
Implement the line validation against the diff before publishing inline comments. If a comment points to a line not present in the diff, skip it and log (instead of failing the job).
See approach
def is_line_in_diff(comment, files_in_diff):
"""Check whether the line is in the diff."""
for f in files_in_diff:
if f.get("new_path") == comment["path"]:
patch = f.get("diff", "")
# Parse hunks: @@ -A,B +C,D @@
for hunk in re.finditer(r"@@\s+-\d+,\d+\s+\+(\d+),(\d+)\s+@@", patch):
start = int(hunk.group(1))
count = int(hunk.group(2))
if start <= comment["line"] < start + count:
return True
return False
# Filter before publishing
valid_comments = [c for c in review["comments"] if is_line_in_diff(c, files)]
Summary
- The GitLab pipeline has 3 stages that separate responsibilities: prepare, analyze, publish
- CI/CD variables with Protected + Masked flags handle secrets
needs:ensures dependencies between jobs and the passing of artifactspython-gitlabsimplifies API calls (vs rawrequests)- Inline comments in GitLab are "discussions" with a
position - A pip cache shared between jobs speeds up the pipeline
- Validating lines in the diff before inline comments avoids errors
Next capsule: 05 — Project: Porting the Module 2 bot to GitLab. You take the working bot you built for GitHub Actions and port it to GitLab, demonstrating cross-platform portability — the module's central lesson.
Additional Resources
- python-gitlab — Official Python client for the GitLab API
- GitLab Merge Requests API — Complete endpoint
- GitLab Notes/Discussions API — For comments and inline comments
- GitLab CI/CD Predefined Variables — Complete list
- Project Access Tokens — Safer than personal PATs
- GitLab Docker Images — Official images