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:

VariableValueFlags
ANTHROPIC_API_KEYyour API keyProtected ✅, Masked ✅
GITLAB_API_TOKENpersonal access token with the api scopeProtected ✅, 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-gitlab instead of requests directly
  • Variables: CI_SERVER_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_IID (not GITHUB_*)
  • 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.json is 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-gitlab simplifies the API calls (instead of raw requests)
  • Inline comments in GitLab are called "discussions" with a position
  • It needs the MR's diff_refs to anchor inline comments to the right diff
  • It handles inline comment errors individually (it doesn't fail the whole job)

Running the Pipeline

  1. Push .gitlab-ci.yml + scripts/ to a branch
  2. Configure the ANTHROPIC_API_KEY and GITLAB_API_TOKEN variables in Settings
  3. Create an MR against main
  4. 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, generates review_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:

  1. The pipeline runs on the MR
  2. The 3 stages run in order
  3. 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 artifacts
  • python-gitlab simplifies API calls (vs raw requests)
  • 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

  1. python-gitlab — Official Python client for the GitLab API
  2. GitLab Merge Requests API — Complete endpoint
  3. GitLab Notes/Discussions API — For comments and inline comments
  4. GitLab CI/CD Predefined Variables — Complete list
  5. Project Access Tokens — Safer than personal PATs
  6. GitLab Docker Images — Official images