Merge branch 'stable' into devel
[tails.git] / bin / gitlab-users-cleanup
blob021ff0642998e5f67e917935fb1a87873577a161
1 #! /usr/bin/python3
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' \
11 #         --active \
12 #         --max-post-sign-up-activity='120 days' \
13 #         --max-contribution-events=0 \
14 #         --action=deactivate-or-block
16 import datetime
17 import logging
18 import os
19 from pathlib import Path
20 import sys
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()
35 LEGIT_USERS = [
36     "adrelanos",
37     "ajitari",
38     "alster",
39     "amartinr",
40     "anarcat",
41     "anonymousblabla",
42     "AnthonyV",
43     "AtomiKe",
44     "Ballen",
45     "bertagaz",
46     "BitingBird",
47     "boklm",
48     "Brettermaik",
49     "cacahuatl",
50     "cacukin",
51     "cailmdaley",
52     "chouchou",
53     "cipherpunks",
54     "co6",
55     "counterflow",
56     "cypherpunks",
57     "CyrilBrulebois",
58     "Dary",
59     "davidiw",
60     "dawuud",
61     "dgoulet",
62     "disoj",
63     "dkg",
64     "diva",
65     "Dr_Whax",
66     "elouann",
67     "enrico",
68     "espiv",
69     "flapflap",
70     "Gaff",
71     "garrettr",
72     "geb",
73     "goupille",
74     "Guillaume",
75     "hellais",
76     "huertanix",
77     "hyas",
78     "import-from-Redmine",
79     "infinity0",
80     "ioerror",
81     "irregulator",
82     "iry",
83     "Joelsven1",
84     "johanbluecreek",
85     "johnnyvon",
86     "juris",
87     "lrnpenguin",
88     "ma1",
89     "maker",
90     "marcelisa2",
91     "matsa",
92     "mercedes508",
93     "micah",
94     "mjenglish",
95     "Moorm",
96     "mrphs",
97     "naar",
98     "nathan",
99     "natmaka",
100     "nc89",
101     "olabini",
102     "OneST8",
103     "ovitters",
104     "pablonatalino",
105     "png",
106     "pragmatic45",
107     "Rajie",
108     "rlrevell",
109     "rojiro",
110     "romeopapa",
111     "runa",
112     "runasand",
113     "s7r",
114     "saintmichael",
115     "sam_krd",
116     "SammyTheDuck",
117     "sascha.markus_gmail.com",
118     "sbrocca",
119     "sdibella",
120     "Seb35",
121     "senkdavid",
122     "singuliere",
123     "sjmurdoch",
124     "Sosthene",
125     "spriver",
126     "sriedel",
127     "sst",
128     "Standard8",
129     "sycamoreone",
130     "synthe",
131     "taggart",
132     "Tanejarahul",
133     "tchou",
134     "TheNerdyAnarchist",
135     "tim",
136     "ttailor",
137     "underite",
138     "usul",
139     "uzairfarooq",
140     "winterfairy",
141     "xin",
142     "xirzon",
143     "xmunoz",
144     "yawning",
145     "zooko",
146     "zopedi",
149 if __name__ == "__main__":
150     import argparse
152     parser = argparse.ArgumentParser()
154     # Filters
155     parser.add_argument(
156         "--blocked", action="store_true", help="Only consider blocked users"
157     )
158     parser.add_argument(
159         "--active", action="store_true", help="Only consider active users"
160     )
161     parser.add_argument(
162         "--deactivated",
163         action="store_true",
164         help="Only consider deactivated users",
165     )
166     parser.add_argument(
167         "--min-creation-age",
168         type=django.utils.dateparse.parse_duration,
169         default="57 days",
170         help="Only consider users created at least this duration ago",
171     )
172     parser.add_argument(
173         "--max-creation-age",
174         type=django.utils.dateparse.parse_duration,
175         help="Only consider users created at most this duration ago",
176     )
177     parser.add_argument(
178         "--min-inactivity",
179         type=django.utils.dateparse.parse_duration,
180         default="56 days",
181         help="Only consider users inactive since this duration",
182     )
183     parser.add_argument(
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",
187     )
188     parser.add_argument(
189         "--max-sign-in-count",
190         type=int,
191         default=7,
192         help="Only consider users who have not signed-in more often than this",
193     )
194     parser.add_argument(
195         "--max-contribution-events",
196         type=int,
197         default=10,
198         help="Only consider users who have not acted on issues or MRs more often than this",
199     )
200     parser.add_argument(
201         "--min-contribution-events",
202         type=int,
203         default=0,
204         help="Only consider users who have acted on issues or MRs at least this often",
205     )
206     parser.add_argument(
207         "--not-in-group",
208         type=str,
209         default="contributors-team",
210         help="Only consider users who are not members of this group",
211     )
212     parser.add_argument(
213         "--search",
214         type=str,
215         help="Only consider users who satisfy this search criterion",
216     )
217     parser.add_argument(
218         "--email-ends-with",
219         type=str,
220         help="Only consider users whose email address ends with this string",
221     )
223     # Actions
224     parser.add_argument(
225         "--action",
226         type=str,
227         help="Action to take on selected users, among: deactivate, block, deactivate-or-block, delete, delete-user-and-contributions",
228     )
230     # General behavior control
231     parser.add_argument("--debug", action="store_true", help="debug output")
232     parser.add_argument(
233         "--dry-run",
234         action="store_true",
235         help="Don't actually update anything, just print",
236     )
238     args = parser.parse_args()
240     if args.deactivated and args.active:
241         sys.exit("Cannot use --deactivated and --active at the same time")
243     if args.debug:
244         logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
245     else:
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]
250     )
251     gl.auth()
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:
267         log.debug(
268             "Max post-sign-up activity: %s", args.max_post_sign_up_activity
269         )
271     if args.not_in_group is not None:
272         group = [
273             g
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
277         ][0]
278         group_members_ids = [m.id for m in group.members_all.list(get_all=True)]
279     else:
280         group_members_ids = []
281     log.debug("Group members: %s", group_members_ids)
283     user_filters = {
284         "exclude_internal": True,
285         "two_factor": "disabled",
286     }
287     if args.blocked:
288         user_filters["blocked"] = True
289     if args.active:
290         user_filters["active"] = True
291     if args.deactivated:
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)
300     for user in 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":
306             log.debug(
307                 "User %s is not deactivated (state: %s) ⇒ skipping",
308                 user_desc,
309                 user.state,
310             )
311             continue
313         if dateutil.parser.isoparse(user.created_at) < max_creation_date:
314             log.debug(
315                 "User %s was created more than %s ago",
316                 user_desc,
317                 args.min_creation_age,
318             )
319             if min_creation_date:
320                 if dateutil.parser.isoparse(user.created_at) > min_creation_date:
321                     log.debug(
322                         "User %s was created less than %s ago",
323                         user_desc,
324                         args.max_creation_age,
325                     )
326                 else:
327                     log.debug(
328                         "User %s was created more than %s ago ⇒ skipping",
329                         user_desc,
330                         args.max_creation_age,
331                     )
332                     continue
333         else:
334             log.debug(
335                 "User %s was created less than %s ago ⇒ skipping",
336                 user_desc,
337                 args.min_creation_age,
338             )
339             continue
341         if user.last_activity_on is None:
342             log.debug("User %s was never active", user_desc)
343         elif (
344             dateutil.parser.isoparse(user.last_activity_on + "T00Z")
345             < max_activity_date
346         ):
347             log.debug(
348                 "User %s is inactive since at least %s",
349                 user_desc,
350                 args.min_inactivity,
351             )
352         else:
353             log.debug(
354                 "User %s was active in the last %s ⇒ skipping",
355                 user_desc,
356                 args.min_inactivity,
357             )
358             continue
360         if (
361             user.last_activity_on is not None
362             and args.max_post_sign_up_activity is not None
363         ):
364             created_at = dateutil.parser.isoparse(user.created_at)
365             last_activity_on = dateutil.parser.isoparse(
366                 user.last_activity_on + "T00Z"
367             )
368             if last_activity_on < created_at + args.max_post_sign_up_activity:
369                 log.debug(
370                     "User %s has not been active for more than %s after sign-up",
371                     user_desc,
372                     args.max_post_sign_up_activity,
373                 )
374             else:
375                 log.debug(
376                     "User %s has been active for more than %s after sign-up ⇒ skipping",
377                     user_desc,
378                     args.max_post_sign_up_activity,
379                 )
380                 continue
382         if user.username in LEGIT_USERS:
383             log.debug(
384                 "User %s is legit ⇒ skipping",
385                 user_desc,
386             )
387             continue
389         if user.id in group_members_ids:
390             log.debug(
391                 "User %s is in group %s ⇒ skipping",
392                 user_desc,
393                 args.not_in_group,
394             )
395             continue
397         if args.email_ends_with is not None:
398             if user.email.endswith(args.email_ends_with):
399                 log.debug(
400                     "User %s has an email address that ends with %s",
401                     user_desc,
402                     args.email_ends_with,
403                 )
404             else:
405                 log.debug(
406                     "User %s has no email address that ends with %s",
407                     user_desc,
408                     args.email_ends_with,
409                 )
410                 continue
412         user_obj = gl.users.get(user.id)
414         if user_obj.sign_in_count <= args.max_sign_in_count:
415             log.debug(
416                 "User %s has signed-in %i <= %i times",
417                 user_desc,
418                 user_obj.sign_in_count,
419                 args.max_sign_in_count,
420             )
421         else:
422             log.debug(
423                 "User %s has signed-in %i > %i times ⇒ skipping",
424                 user_desc,
425                 user_obj.sign_in_count,
426                 args.max_sign_in_count,
427             )
428             continue
430         events = user_obj.events.list(all=True)
431         contribution_events = [
432             e
433             for e in events
434             if e.target_type
435             in ["Note", "DiscussionNote", "Issue", "merge_request"]
436         ]
437         if len(contribution_events) <= args.max_contribution_events:
438             log.debug(
439                 "User %s has done less than %i contributions",
440                 user_desc,
441                 args.max_contribution_events,
442             )
443         else:
444             log.debug(
445                 "User %s has done at least %i contributions ⇒ skipping",
446                 user_desc,
447                 args.max_contribution_events,
448             )
449             continue
450         if len(contribution_events) >= args.min_contribution_events:
451             log.debug(
452                 "User %s has done at least %i contributions",
453                 user_desc,
454                 args.min_contribution_events,
455             )
456         else:
457             log.debug(
458                 "User %s has done less than %i contributions ⇒ skipping",
459                 user_desc,
460                 args.min_contribution_events,
461             )
462             continue
464         # If we reached this point, perform args.action
466         if args.action == "deactivate":
467             if user.state == "blocked":
468                 log.debug(
469                     "User %s is already blocked, cannot deactivate", user_desc
470                 )
471             elif user.state == "deactivated":
472                 log.debug("User %s is already deactivated", user_desc)
473             else:
474                 log.info(
475                     "Deactivating user %s (previous state: %s)",
476                     user_desc,
477                     user.state,
478                 )
479                 if not args.dry_run:
480                     user.deactivate()
481         elif args.action == "block":
482             if user.state == "blocked":
483                 log.debug("User %s is already blocked", user_desc)
484             else:
485                 log.info(
486                     "Blocking user %s (previous state: %s)",
487                     user_desc,
488                     user.state,
489                 )
490                 if not args.dry_run:
491                     user.block()
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)
495             else:
496                 log.info(
497                     "Deactivating user %s (previous state: %s)",
498                     user_desc,
499                     user.state,
500                 )
501                 if not args.dry_run:
502                     try:
503                         user.deactivate()
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:
507                         log.info(
508                             "Deactivating user %s (previous state: %s) failed, so blocking them",
509                             user_desc,
510                             user.state,
511                         )
512                         user.block()
513         elif args.action == "delete":
514             log.info(
515                 "Deleting user %s (previous state: %s): https://gitlab.tails.boum.org/%s",
516                 user_desc,
517                 user.state,
518                 user.username,
519             )
520             if not args.dry_run:
521                 user.delete()
522         elif args.action == "delete-user-and-contributions":
523             log.info(
524                 "Deleting user %s and contributions (previous state: %s): https://gitlab.tails.boum.org/%s",
525                 user_desc,
526                 user.state,
527                 user.username,
528             )
529             if not args.dry_run:
530                 user.delete(hard_delete=True)
531         else:
532             sys.exit("Unsupported action: %s" % args.action)