3 # Documentation: https://tails.net/contribute/working_together/GitLab/#api
5 # Usage example, from when we had lots of SPAM users and used this script
6 # during the Ticket Gardening process:
8 # GITLAB_NAME=TailsRoot ./bin/gitlab-users-cleanup \
9 # --min-creation-age='29 days' \
10 # --min-inactivity='28 days' \
12 # --max-post-sign-up-activity='120 days' \
13 # --max-contribution-events=0 \
14 # --action=deactivate-or-block
19 from pathlib import Path
22 import dateutil.parser
23 import gitlab # type: ignore
24 import django.utils.dateparse # type: ignore
26 PYTHON_GITLAB_CONFIG_FILE = os.getenv(
27 "PYTHON_GITLAB_CONFIG_FILE", default=Path.home() / ".python-gitlab.cfg"
30 PYTHON_GITLAB_NAME = os.getenv("GITLAB_NAME", default="Tails")
32 LOG_FORMAT = "%(asctime)-15s %(levelname)s %(message)s"
33 log = logging.getLogger()
78 "import-from-Redmine",
117 "sascha.markus_gmail.com",
149 if __name__ == "__main__":
152 parser = argparse.ArgumentParser()
156 "--blocked", action="store_true", help="Only consider blocked users"
159 "--active", action="store_true", help="Only consider active users"
164 help="Only consider deactivated users",
167 "--min-creation-age",
168 type=django.utils.dateparse.parse_duration,
170 help="Only consider users created at least this duration ago",
173 "--max-creation-age",
174 type=django.utils.dateparse.parse_duration,
175 help="Only consider users created at most this duration ago",
179 type=django.utils.dateparse.parse_duration,
181 help="Only consider users inactive since this duration",
184 "--max-post-sign-up-activity",
185 type=django.utils.dateparse.parse_duration,
186 help="Don't consider users who have been active for at least this duration after signing-up",
189 "--max-sign-in-count",
192 help="Only consider users who have not signed-in more often than this",
195 "--max-contribution-events",
198 help="Only consider users who have not acted on issues or MRs more often than this",
201 "--min-contribution-events",
204 help="Only consider users who have acted on issues or MRs at least this often",
209 default="contributors-team",
210 help="Only consider users who are not members of this group",
215 help="Only consider users who satisfy this search criterion",
220 help="Only consider users whose email address ends with this string",
227 help="Action to take on selected users, among: deactivate, block, deactivate-or-block, delete, delete-user-and-contributions",
230 # General behavior control
231 parser.add_argument("--debug", action="store_true", help="debug output")
235 help="Don't actually update anything, just print",
238 args = parser.parse_args()
240 if args.deactivated and args.active:
241 sys.exit("Cannot use --deactivated and --active at the same time")
244 logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
246 logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
248 gl = gitlab.Gitlab.from_config(
249 PYTHON_GITLAB_NAME, config_files=[PYTHON_GITLAB_CONFIG_FILE]
253 now = datetime.datetime.now(tz=datetime.timezone.utc)
255 max_creation_date = now - args.min_creation_age
256 log.debug("Max creation date: %s", max_creation_date)
258 min_creation_date = None
259 if args.max_creation_age:
260 min_creation_date = now - args.max_creation_age
261 log.debug("Min creation date: %s", min_creation_date)
263 max_activity_date = now - args.min_inactivity
264 log.debug("Max activity date: %s", max_activity_date)
266 if args.max_post_sign_up_activity is not None:
268 "Max post-sign-up activity: %s", args.max_post_sign_up_activity
271 if args.not_in_group is not None:
274 for g in gl.groups.list(all=True)
275 # Disambiguate between groups whose names share a common prefix
276 if g.full_path == args.not_in_group
278 group_members_ids = [m.id for m in group.members_all.list(get_all=True)]
280 group_members_ids = []
281 log.debug("Group members: %s", group_members_ids)
284 "exclude_internal": True,
285 "two_factor": "disabled",
288 user_filters["blocked"] = True
290 user_filters["active"] = True
292 user_filters["active"] = False
293 if args.search is not None:
294 user_filters["search"] = args.search
296 users = gl.users.list(all=True, iterator=True, **user_filters)
298 log.debug("Users: %s", users)
301 user_desc = f"{user.username} (id={user.id})"
303 # Filter out users we don't want to act upon
305 if args.deactivated and user.state != "deactivated":
307 "User %s is not deactivated (state: %s) ⇒ skipping",
313 if dateutil.parser.isoparse(user.created_at) < max_creation_date:
315 "User %s was created more than %s ago",
317 args.min_creation_age,
319 if min_creation_date:
320 if dateutil.parser.isoparse(user.created_at) > min_creation_date:
322 "User %s was created less than %s ago",
324 args.max_creation_age,
328 "User %s was created more than %s ago ⇒ skipping",
330 args.max_creation_age,
335 "User %s was created less than %s ago ⇒ skipping",
337 args.min_creation_age,
341 if user.last_activity_on is None:
342 log.debug("User %s was never active", user_desc)
344 dateutil.parser.isoparse(user.last_activity_on + "T00Z")
348 "User %s is inactive since at least %s",
354 "User %s was active in the last %s ⇒ skipping",
361 user.last_activity_on is not None
362 and args.max_post_sign_up_activity is not None
364 created_at = dateutil.parser.isoparse(user.created_at)
365 last_activity_on = dateutil.parser.isoparse(
366 user.last_activity_on + "T00Z"
368 if last_activity_on < created_at + args.max_post_sign_up_activity:
370 "User %s has not been active for more than %s after sign-up",
372 args.max_post_sign_up_activity,
376 "User %s has been active for more than %s after sign-up ⇒ skipping",
378 args.max_post_sign_up_activity,
382 if user.username in LEGIT_USERS:
384 "User %s is legit ⇒ skipping",
389 if user.id in group_members_ids:
391 "User %s is in group %s ⇒ skipping",
397 if args.email_ends_with is not None:
398 if user.email.endswith(args.email_ends_with):
400 "User %s has an email address that ends with %s",
402 args.email_ends_with,
406 "User %s has no email address that ends with %s",
408 args.email_ends_with,
412 user_obj = gl.users.get(user.id)
414 if user_obj.sign_in_count <= args.max_sign_in_count:
416 "User %s has signed-in %i <= %i times",
418 user_obj.sign_in_count,
419 args.max_sign_in_count,
423 "User %s has signed-in %i > %i times ⇒ skipping",
425 user_obj.sign_in_count,
426 args.max_sign_in_count,
430 events = user_obj.events.list(all=True)
431 contribution_events = [
435 in ["Note", "DiscussionNote", "Issue", "merge_request"]
437 if len(contribution_events) <= args.max_contribution_events:
439 "User %s has done less than %i contributions",
441 args.max_contribution_events,
445 "User %s has done at least %i contributions ⇒ skipping",
447 args.max_contribution_events,
450 if len(contribution_events) >= args.min_contribution_events:
452 "User %s has done at least %i contributions",
454 args.min_contribution_events,
458 "User %s has done less than %i contributions ⇒ skipping",
460 args.min_contribution_events,
464 # If we reached this point, perform args.action
466 if args.action == "deactivate":
467 if user.state == "blocked":
469 "User %s is already blocked, cannot deactivate", user_desc
471 elif user.state == "deactivated":
472 log.debug("User %s is already deactivated", user_desc)
475 "Deactivating user %s (previous state: %s)",
481 elif args.action == "block":
482 if user.state == "blocked":
483 log.debug("User %s is already blocked", user_desc)
486 "Blocking user %s (previous state: %s)",
492 elif args.action == "deactivate-or-block":
493 if user.state in ["blocked", "deactivated"]:
494 log.debug("User %s is already %s", user_desc, user.state)
497 "Deactivating user %s (previous state: %s)",
504 # The GitLab API forbids deactivating a user who
505 # has been active in the past 90 days, so block them.
506 except gitlab.exceptions.GitlabDeactivateError:
508 "Deactivating user %s (previous state: %s) failed, so blocking them",
513 elif args.action == "delete":
515 "Deleting user %s (previous state: %s): https://gitlab.tails.boum.org/%s",
522 elif args.action == "delete-user-and-contributions":
524 "Deleting user %s and contributions (previous state: %s): https://gitlab.tails.boum.org/%s",
530 user.delete(hard_delete=True)
532 sys.exit("Unsupported action: %s" % args.action)