diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 5b424809213..283d6c297e8 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -77,7 +77,7 @@ Saving the CSVs grants the access listed in them. Changing a membership in GitLa Download [export-permissions.py](/static/examples/gitlab/export-permissions.py) and save it as `ci/export-gitlab-permissions.py` in your repository. It uses Python 3.10 or later with no extra packages and exports one project per run. -Use GitLab 17.4 or later and an internal export token with `read_api` and permission to see all members of the target project and their primary emails. Keep this token in a masked, protected CI variable named `GITLAB_EXPORT_TOKEN`. It stays in your CI job; Sim receives the CSVs. The source's separate read-only PAT continues to retrieve content. +Use GitLab 17.4 or later and an internal export token with `read_api` and permission to see all members of the target project, their primary emails, and confirmation status. Keep this token in a masked, protected CI variable named `GITLAB_EXPORT_TOKEN`. It stays in your CI job; Sim receives the CSVs. The source's separate read-only PAT continues to retrieve content. The exporter makes only these GitLab API requests: @@ -85,9 +85,9 @@ The exporter makes only these GitLab API requests: | --- | --- | | `GET /api/v4/projects/:id` | Resolve the numeric ID and full project path. | | `GET /api/v4/projects/:id/members/all` | Page through visible direct, inherited, and invited-group members, 100 at a time. | -| `GET /api/v4/users/:id` | Read each eligible member's account state and primary email. | +| `GET /api/v4/users/:id` | Read each eligible member's account state, email confirmation, and primary email. | -GitLab documents [membership visibility](https://docs.gitlab.com/api/project_members/#list-all-members-of-a-project) and [user profile fields](https://docs.gitlab.com/api/users/#retrieve-a-single-user). The `read_api` scope alone does not grant access to private emails. The export token needs that directory permission too. If a primary email is unavailable, the script stops; it never substitutes a public email. Confirm the exported emails match your teammates' verified Sim emails. +GitLab documents [membership visibility](https://docs.gitlab.com/api/project_members/#list-all-members-of-a-project) and [user profile fields](https://docs.gitlab.com/api/users/#retrieve-a-single-user). The `read_api` scope alone does not grant access to private emails. The export token needs that directory permission too. Profiles without a valid `confirmed_at` timestamp are excluded. If an eligible reader's primary email is unavailable, the script stops; it never substitutes a public email. Confirm the exported emails match your teammates' verified Sim emails. With `GITLAB_EXPORT_TOKEN` set in the environment, run: diff --git a/apps/docs/public/static/examples/gitlab/export-permissions.py b/apps/docs/public/static/examples/gitlab/export-permissions.py index d6891b16405..33a53d049c5 100644 --- a/apps/docs/public/static/examples/gitlab/export-permissions.py +++ b/apps/docs/public/static/examples/gitlab/export-permissions.py @@ -140,6 +140,13 @@ def export(client, project): raise ExportError("GitLab returned a mismatched user identity.") if not active(profile) or profile.get("bot", False): continue + confirmed_at = profile.get("confirmed_at") + if not isinstance(confirmed_at, str): + continue + try: + datetime.fromisoformat(confirmed_at.replace("Z", "+00:00")) + except ValueError: + continue email = profile.get("email") if not isinstance(email, str): raise ExportError( diff --git a/apps/docs/scripts/test-gitlab-export-e2e.py b/apps/docs/scripts/test-gitlab-export-e2e.py index 9acac3079e0..8f3075e033b 100644 --- a/apps/docs/scripts/test-gitlab-export-e2e.py +++ b/apps/docs/scripts/test-gitlab-export-e2e.py @@ -78,7 +78,14 @@ def do_GET(self): if scenario == "duplicate-email" and user_id == 2: email = "alice@example.com" profile = {"id": user_id, "state": "active", "locked": user_id == 9, - "bot": user_id == 5, "email": email} + "bot": user_id == 5, "email": email, "confirmed_at": "2025-01-01T00:00:00Z"} + if user_id == 2: + if scenario == "unconfirmed-email": + profile["confirmed_at"] = None + elif scenario == "missing-confirmation": + profile.pop("confirmed_at") + elif scenario == "invalid-confirmation": + profile["confirmed_at"] = "not-a-timestamp" if scenario == "hidden-email": profile.pop("email") profile["public_email"] = f"public-alias-{user_id}@example.com" @@ -121,6 +128,8 @@ def run(): for scenario, expected_error in [ ("success", None), ("numeric-project", None), ("missing-pagination", None), ("issues-only", None), ("paced", None), + ("unconfirmed-email", None), ("missing-confirmation", None), + ("invalid-confirmation", None), ("truncated-membership", "total"), ("changing-total", "changed"), ("hidden-email", "email"), ("duplicate-email", "email"), ("wrong-identity", "identity"), ("denied", "403"), @@ -157,6 +166,8 @@ def run(): expected = [["7", "carol@example.com"]] if scenario == "missing-pagination" else [ ["1", "alice@example.com"], ["2", "bob@example.com"], ["7", "carol@example.com"]] + if scenario in ("unconfirmed-email", "missing-confirmation", "invalid-confirmation"): + expected = [["1", "alice@example.com"], ["7", "carol@example.com"]] assert users == [["user_id", "email"], *expected], users assert grants == [["project_path", "user_id"], *[ ["engineering/platform", row[0]] for row in expected]], grants